diff --git a/.bundler-audit.yml b/.bundler-audit.yml deleted file mode 100644 index f84ec808726..00000000000 --- a/.bundler-audit.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -ignore: - - CVE-2015-9284 # Mitigation following https://github.com/omniauth/omniauth/wiki/Resolving-CVE-2015-9284#mitigating-in-rails-applications diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 3913a6b0f80..00000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,128 +0,0 @@ -version: 2.1 - -orbs: - ruby: circleci/ruby@2.0.0 - node: circleci/node@5.0.3 - -executors: - default: - parameters: - ruby-version: - type: string - docker: - - image: cimg/ruby:<< parameters.ruby-version >> - environment: - BUNDLE_JOBS: 3 - BUNDLE_RETRY: 3 - CONTINUOUS_INTEGRATION: true - DB_HOST: localhost - DB_USER: root - DISABLE_SIMPLECOV: true - RAILS_ENV: test - - image: cimg/postgres:14.5 - environment: - POSTGRES_USER: root - POSTGRES_HOST_AUTH_METHOD: trust - - image: cimg/redis:7.0 - -commands: - install-system-dependencies: - steps: - - run: - name: Install system dependencies - command: | - sudo apt-get update - sudo apt-get install -y libicu-dev libidn11-dev - install-ruby-dependencies: - parameters: - ruby-version: - type: string - steps: - - run: - command: | - bundle config clean 'true' - bundle config frozen 'true' - bundle config without 'development production' - name: Set bundler settings - - ruby/install-deps: - bundler-version: '2.3.26' - key: ruby<< parameters.ruby-version >>-gems-v2 - wait-db: - steps: - - run: - command: dockerize -wait tcp://localhost:5432 -wait tcp://localhost:6379 -timeout 1m - name: Wait for PostgreSQL and Redis - -jobs: - build: - docker: - - image: cimg/ruby:3.2-node - environment: - RAILS_ENV: test - steps: - - checkout - - install-system-dependencies - - install-ruby-dependencies: - ruby-version: '3.2' - - node/install-packages: - cache-version: v1 - pkg-manager: yarn - - run: - command: | - export NODE_OPTIONS=--openssl-legacy-provider - ./bin/rails assets:precompile - name: Precompile assets - - persist_to_workspace: - paths: - - public/assets - - public/packs-test - root: . - - test: - parameters: - ruby-version: - type: string - executor: - name: default - ruby-version: << parameters.ruby-version >> - environment: - ALLOW_NOPAM: true - PAM_ENABLED: true - PAM_DEFAULT_SERVICE: pam_test - PAM_CONTROLLED_SERVICE: pam_test_controlled - parallelism: 4 - steps: - - checkout - - install-system-dependencies - - run: - command: sudo apt-get install -y ffmpeg imagemagick libmagickcore-dev libmagickwand-dev libjpeg-dev libpng-dev libtiff-dev libwebp-dev libpam-dev - name: Install additional system dependencies - - run: - command: bundle config with 'pam_authentication' - name: Enable PAM authentication - - install-ruby-dependencies: - ruby-version: << parameters.ruby-version >> - - attach_workspace: - at: . - - wait-db - - run: - command: ./bin/rails db:create db:schema:load db:seed - name: Load database schema - - ruby/rspec-test - -workflows: - version: 2 - build-and-test: - jobs: - - build - - test: - matrix: - parameters: - ruby-version: - - '2.7' - - '3.0' - - '3.1' - - '3.2' - name: test-ruby<< matrix.ruby-version >> - requires: - - build diff --git a/.codeclimate.yml b/.codeclimate.yml deleted file mode 100644 index 00469df005e..00000000000 --- a/.codeclimate.yml +++ /dev/null @@ -1,39 +0,0 @@ -version: '2' -checks: - argument-count: - enabled: false - complex-logic: - enabled: false - file-lines: - enabled: false - method-complexity: - enabled: false - method-count: - enabled: false - method-lines: - enabled: false - nested-control-flow: - enabled: false - return-statements: - enabled: false - similar-code: - enabled: false - identical-code: - enabled: false -plugins: - brakeman: - enabled: true - bundler-audit: - enabled: false - eslint: - enabled: false - rubocop: - enabled: false - sass-lint: - enabled: false -exclude_patterns: - - spec/ - - vendor/asset/ - - - app/javascript/mastodon/locales/**/*.json - - config/locales/**/*.yml diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 425b86a6bb6..b3b1d97a241 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,16 +1,10 @@ -# [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.1, 3.0, 2, 2.7, 2.6, 3-bullseye, 3.1-bullseye, 3.0-bullseye, 2-bullseye, 2.7-bullseye, 2.6-bullseye, 3-buster, 3.1-buster, 3.0-buster, 2-buster, 2.7-buster, 2.6-buster -ARG VARIANT=3.1-bullseye -FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT} +# For details, see https://github.com/devcontainers/images/tree/main/src/ruby +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" - -# [Choice] Node.js version: lts/*, 18, 16, 14 -ARG NODE_VERSION="lts/*" +ARG NODE_VERSION="16" 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. @@ -22,3 +16,5 @@ 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 + +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 00000000000..ca9156fdaa4 --- /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 6ac6993ee91..fa8d6542c18 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,39 +1,40 @@ { - "name": "Mastodon", + "name": "Mastodon on local machine", "dockerComposeFile": "docker-compose.yml", "service": "app", - "workspaceFolder": "/mastodon", - - // 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", - "dbaeumer.vscode-eslint", - "rebornix.Ruby", - "webben.browserslist" - ] - } - }, + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "features": { - "ghcr.io/devcontainers/features/sshd:1": { - "version": "latest" + "ghcr.io/devcontainers/features/sshd:1": {} + }, + + "forwardPorts": [3000, 4000], + + "portsAttributes": { + "3000": { + "label": "web", + "onAutoForward": "notify", + "requireLocalPort": true + }, + "4000": { + "label": "stream", + "onAutoForward": "silent", + "requireLocalPort": true } }, - // 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], + "otherPortsAttributes": { + "onAutoForward": "silent" + }, - // Use 'postCreateCommand' to run commands after the container is created. + "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", "postCreateCommand": ".devcontainer/post-create.sh", + "waitFor": "postCreateCommand", - // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "vscode" + "customizations": { + "vscode": { + "settings": {}, + "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"] + } + } } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 95f401379c8..0369521963f 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -5,19 +5,12 @@ services: build: context: . dockerfile: Dockerfile - args: - # Update 'VARIANT' to pick a version of Ruby: 3, 3.1, 3.0, 2, 2.7, 2.6 - # Append -bullseye or -buster to pin to an OS version. - # Use -bullseye variants on local arm64/Apple Silicon. - VARIANT: '3.0-bullseye' - # Optional Node.js version to install - NODE_VERSION: '16' volumes: - - ..:/mastodon:cached + - ../..:/workspaces:cached environment: RAILS_ENV: development NODE_ENV: development - + BIND: 0.0.0.0 REDIS_HOST: redis REDIS_PORT: '6379' DB_HOST: db @@ -30,10 +23,13 @@ services: LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000 # Overrides default command so things don't shut down after the process ends. 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 - internal_network - user: vscode db: image: postgres:14-alpine @@ -49,7 +45,7 @@ services: - internal_network redis: - image: redis:6-alpine + image: redis:7-alpine restart: unless-stopped volumes: - redis-data:/data @@ -74,15 +70,19 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.2.9 + image: libretranslate/libretranslate:v1.3.12 restart: unless-stopped + volumes: + - lt-data:/home/libretranslate/.local networks: + - external_network - internal_network volumes: postgres-data: redis-data: es-data: + lt-data: networks: external_network: diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 02f488f1202..a075cc7b3bc 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -3,17 +3,22 @@ set -e # Fail the whole script on first error # Fetch Ruby gem dependencies -bundle install --path vendor/bundle --with='development test' - -# Fetch Javascript dependencies -yarn install +bundle config path 'vendor/bundle' +bundle config with 'development test' +bundle install # Make Gemfile.lock pristine again git checkout -- Gemfile.lock +# Fetch Javascript dependencies +yarn --frozen-lockfile + # [re]create, migrate, and seed the test database RAILS_ENV=test ./bin/rails db:setup +# [re]create, migrate, and seed the development database +RAILS_ENV=development ./bin/rails db:setup + # Precompile assets for development RAILS_ENV=development ./bin/rails assets:precompile diff --git a/.devcontainer/welcome-message.txt b/.devcontainer/welcome-message.txt new file mode 100644 index 00000000000..488cf92857a --- /dev/null +++ b/.devcontainer/welcome-message.txt @@ -0,0 +1,8 @@ +👋 Welcome to "Mastodon" in GitHub Codespaces! + +🛠️ Your environment is fully setup with all the required software. + +🔍 To explore VS Code to its fullest, search using the Command Palette (Cmd/Ctrl + Shift + P or F1). + +📝 Edit away, run your app as usual, and we'll automatically make it available for you to access. + diff --git a/.env.vagrant b/.env.vagrant index 32ed9b92294..69c1bf1fb3d 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 606a87e415a..70506f60c48 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,75 +4,67 @@ module.exports = { extends: [ 'eslint:recommended', 'plugin:react/recommended', + 'plugin:react-hooks/recommended', 'plugin:jsx-a11y/recommended', 'plugin:import/recommended', 'plugin:promise/recommended', + 'plugin:jsdoc/recommended', ], env: { browser: true, node: true, es6: true, - jest: true, }, globals: { ATTACHMENT_HOST: false, }, - parser: '@babel/eslint-parser', + parser: '@typescript-eslint/parser', plugins: [ 'react', 'jsx-a11y', 'import', 'promise', + '@typescript-eslint', + 'formatjs', ], parserOptions: { sourceType: 'module', ecmaFeatures: { - experimentalObjectRestSpread: true, jsx: true, }, ecmaVersion: 2021, + requireConfigFile: false, + babelOptions: { + configFile: false, + presets: ['@babel/react', '@babel/env'], + }, }, settings: { react: { version: 'detect', }, - 'import/extensions': [ - '.js', '.jsx', - ], 'import/ignore': [ 'node_modules', '\\.(css|scss|json)$', ], 'import/resolver': { - node: { - paths: ['app/javascript'], - extensions: ['.js', '.jsx'], - }, + typescript: {}, }, }, rules: { - 'brace-style': 'warn', - 'comma-dangle': ['error', 'always-multiline'], - 'comma-spacing': [ - 'warn', - { - before: false, - after: true, - }, - ], - 'comma-style': ['warn', 'last'], 'consistent-return': 'error', 'dot-notation': 'error', - eqeqeq: 'error', - indent: ['warn', 2], + 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': [ @@ -90,42 +82,43 @@ module.exports = { { property: 'substring', message: 'Use .slice instead of .substring.' }, { property: 'substr', message: 'Use .slice instead of .substr.' }, ], + 'no-restricted-syntax': [ + 'error', + { + // eslint-disable-next-line no-restricted-syntax + selector: 'Literal[value=/•/], JSXText[value=/•/]', + // eslint-disable-next-line no-restricted-syntax + message: "Use '·' (middle dot) instead of '•' (bullet)", + }, + ], 'no-self-assign': 'off', - 'no-trailing-spaces': 'warn', 'no-unused-expressions': 'error', - 'no-unused-vars': [ + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ 'error', { vars: 'all', args: 'after-used', + destructuredArrayIgnorePattern: '^_', ignoreRestSiblings: true, }, ], - 'object-curly-spacing': ['error', 'always'], - 'padded-blocks': [ - 'error', - { - classes: 'always', - }, - ], - quotes: ['error', 'single'], - semi: 'error', 'valid-typeof': 'error', - 'react/jsx-filename-extension': ['error', { 'allow': 'as-needed' }], + 'react/jsx-filename-extension': ['error', { extensions: ['.jsx', 'tsx'] }], 'react/jsx-boolean-value': 'error', - 'react/jsx-closing-bracket-location': ['error', 'line-aligned'], - 'react/jsx-curly-spacing': 'error', 'react/display-name': 'off', + 'react/jsx-fragments': ['error', 'syntax'], 'react/jsx-equals-spacing': 'error', - 'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'], - 'react/jsx-indent': ['error', 2], 'react/jsx-no-bind': 'error', + 'react/jsx-no-useless-fragment': 'error', 'react/jsx-no-target-blank': 'off', 'react/jsx-tag-spacing': 'error', + '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', // recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/index.js @@ -188,21 +181,82 @@ module.exports = { { js: 'never', jsx: 'never', + mjs: 'never', + ts: 'never', + tsx: 'never', }, ], + 'import/first': 'error', 'import/newline-after-import': 'error', + 'import/no-anonymous-default-export': 'error', 'import/no-extraneous-dependencies': [ 'error', { devDependencies: [ 'config/webpack/**', + 'app/javascript/mastodon/performance.js', 'app/javascript/mastodon/test_setup.js', 'app/javascript/**/__tests__/**', ], }, ], + 'import/no-amd': 'error', + 'import/no-commonjs': 'error', + 'import/no-import-module-exports': 'error', + 'import/no-relative-packages': 'error', + 'import/no-self-import': 'error', + 'import/no-useless-path-segments': 'error', 'import/no-webpack-loader-syntax': 'error', + 'import/order': [ + 'error', + { + alphabetize: { order: 'asc' }, + 'newlines-between': 'always', + groups: [ + 'builtin', + 'external', + 'internal', + 'parent', + ['index', 'sibling'], + 'object', + ], + pathGroups: [ + // React core packages + { + pattern: '{react,react-dom,react-dom/client,prop-types}', + group: 'builtin', + position: 'after', + }, + // I18n + { + pattern: '{react-intl,intl-messageformat}', + group: 'builtin', + position: 'after', + }, + // Common React utilities + { + 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}', + group: 'external', + position: 'before', + }, + // Internal packages + { + pattern: '{mastodon/**}', + group: 'internal', + position: 'after', + }, + ], + pathGroupsExcludedImportTypes: [], + }, + ], + 'promise/always-return': 'off', 'promise/catch-or-return': [ 'error', @@ -213,5 +267,119 @@ module.exports = { 'promise/no-callback-in-promise': 'off', 'promise/no-nesting': 'off', 'promise/no-promise-in-callback': 'off', + + 'formatjs/blocklist-elements': 'error', + 'formatjs/enforce-default-message': ['error', 'literal'], + 'formatjs/enforce-description': 'off', // description values not currently used + 'formatjs/enforce-id': 'off', // Explicit IDs are used in the project + 'formatjs/enforce-placeholders': 'off', // Issues in short_number.jsx + 'formatjs/enforce-plural-rules': 'error', + 'formatjs/no-camel-case': 'off', // disabledAccount is only non-conforming + 'formatjs/no-complex-selectors': 'error', + 'formatjs/no-emoji': 'error', + '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', + 'formatjs/prefer-formatted-message': 'error', + 'formatjs/prefer-pound-in-plural': 'error', + + 'jsdoc/check-types': 'off', + 'jsdoc/no-undefined-types': 'off', + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-property-description': 'off', + 'jsdoc/require-returns-description': 'off', + 'jsdoc/require-returns': 'off', }, + + overrides: [ + { + files: [ + '*.config.js', + '.*rc.js', + 'ide-helper.js', + 'config/webpack/**/*', + 'config/formatjs-formatter.js', + ], + + env: { + commonjs: true, + }, + + parserOptions: { + sourceType: 'script', + }, + + rules: { + 'import/no-commonjs': 'off', + }, + }, + { + files: [ + '**/*.ts', + '**/*.tsx', + ], + + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/strict-type-checked', + 'plugin:@typescript-eslint/stylistic-type-checked', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:jsx-a11y/recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + 'plugin:promise/recommended', + 'plugin:jsdoc/recommended-typescript', + 'plugin:prettier/recommended', + ], + + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + }, + + rules: { + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], + + '@typescript-eslint/consistent-type-definitions': ['warn', 'interface'], + '@typescript-eslint/consistent-type-exports': 'error', + '@typescript-eslint/consistent-type-imports': 'error', + "@typescript-eslint/prefer-nullish-coalescing": ['error', {ignorePrimitives: {boolean: true}}], + + 'jsdoc/require-jsdoc': 'off', + + // Those rules set stricter rules for TS files + // to enforce better practices when converting from JS + 'import/no-default-export': 'warn', + 'react/prefer-stateless-function': 'warn', + 'react/function-component-definition': ['error', { namedComponents: 'arrow-function' }], + 'react/jsx-uses-react': 'off', // not needed with new JSX transform + 'react/react-in-jsx-scope': 'off', // not needed with new JSX transform + 'react/prop-types': 'off', + }, + }, + { + files: [ + '**/__tests__/*.js', + '**/__tests__/*.jsx', + ], + + 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 be750a5e410..00000000000 --- 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 22f51f7bdfc..00000000000 --- 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 00000000000..20e27d103cd --- /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 00000000000..49d5f57209f --- /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 00000000000..c0f2957fb18 --- /dev/null +++ b/.github/actions/setup-javascript/action.yml @@ -0,0 +1,19 @@ +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@v3 + with: + cache: yarn + node-version-file: '.nvmrc' + + - name: Install all yarn packages + shell: bash + run: yarn --frozen-lockfile ${{ inputs.onlyProduction != 'false' && '--production' || '' }} diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml new file mode 100644 index 00000000000..3a6fba94020 --- /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/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 74d64620ebf..00000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,45 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - - package-ecosystem: npm - directory: '/' - schedule: - interval: weekly - open-pull-requests-limit: 99 - allow: - - dependency-type: direct - - - package-ecosystem: bundler - directory: '/' - schedule: - interval: weekly - open-pull-requests-limit: 99 - allow: - - dependency-type: direct - - - package-ecosystem: github-actions - directory: '/' - schedule: - interval: weekly - open-pull-requests-limit: 99 - allow: - - dependency-type: direct - - - package-ecosystem: docker - directory: '/' - schedule: - interval: weekly - open-pull-requests-limit: 99 - ignore: - - dependency-name: 'moritzheiber/ruby-jemalloc' - update-types: - # only suggest patch releases for ruby and needs to sync with .ruby-version - - 'version-update:semver-minor' - - dependency-name: 'node' - update-types: - # only node minor releases allowed unless .nvmrc major is changed - - 'version-update:semver-major' diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 00000000000..e3744ee6dcc --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,123 @@ +{ + $schema: 'https://docs.renovatebot.com/renovate-schema.json', + extends: [ + 'config:recommended', + ':labels(dependencies)', + ':prConcurrentLimitNone', // Remove limit for open PRs at any time. + ':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour. + ], + 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, + // 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).', + packageRules: [ + { + // Require Dependency Dashboard Approval for major version bumps of these node packages + matchManagers: ['npm'], + matchPackageNames: [ + 'tesseract.js', // Requires code changes + 'react-hotkeys', // Requires code changes + + // Requires Webpacker upgrade or replacement + '@types/webpack', + 'babel-loader', + 'compression-webpack-plugin', + 'css-loader', + 'imports-loader', + 'mini-css-extract-plugin', + 'postcss-loader', + 'sass-loader', + 'terser-webpack-plugin', + 'webpack', + 'webpack-assets-manifest', + 'webpack-bundle-analyzer', + 'webpack-dev-server', + 'webpack-cli', + + // react-router: Requires manual upgrade + 'history', + 'react-router-dom', + ], + matchUpdateTypes: ['major'], + dependencyDashboardApproval: true, + }, + { + // Require Dependency Dashboard Approval for major version bumps of these Ruby packages + matchManagers: ['bundler'], + matchPackageNames: [ + 'rack', // Needs to be synced with Rails version + 'sprockets', // Requires manual upgrade https://github.com/rails/sprockets/blob/master/UPGRADING.md#guide-to-upgrading-from-sprockets-3x-to-4x + '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 + ], + matchUpdateTypes: ['major'], + dependencyDashboardApproval: true, + }, + { + // Update Github Actions and Docker images weekly + matchManagers: ['github-actions', 'dockerfile', 'docker-compose'], + extends: ['schedule:weekly'], + }, + { + // 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'], + dependencyDashboardApproval: true, + }, + { + // Require Dependency Dashboard Approval for major bumps for the node image, this needs to be synced with .nvmrc + matchManagers: ['dockerfile'], + matchPackageNames: ['node'], + matchUpdateTypes: ['major'], + dependencyDashboardApproval: true, + }, + { + // 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'], + dependencyDashboardApproval: true, + }, + { + // Update devDependencies every week, with one grouped PR + matchDepTypes: 'devDependencies', + matchUpdateTypes: ['patch', 'minor'], + 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)', + }, + { + // Update @types/* packages every week, with one grouped PR + matchPackagePrefixes: '@types/', + matchUpdateTypes: ['patch', 'minor'], + groupName: 'DefinitelyTyped types (non-major)', + 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'] }, + { matchManagers: ['docker-compose', 'dockerfile'], addLabels: ['docker'] }, + { matchManagers: ['github-actions'], addLabels: ['github_actions'] }, + ], +} diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml new file mode 100644 index 00000000000..29868c72f8a --- /dev/null +++ b/.github/workflows/build-container-image.yml @@ -0,0 +1,99 @@ +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 + +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: . + 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 c567cd9c3a7..00000000000 --- a/.github/workflows/build-image.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Build container image -on: - workflow_dispatch: - push: - branches: - - 'main' - tags: - - '*' - pull_request: - paths: - - .github/workflows/build-image.yml - - Dockerfile -permissions: - contents: read - -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 - - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - if: github.event_name != 'pull_request' - - uses: docker/metadata-action@v4 - id: meta - with: - images: tootsuite/mastodon - flavor: | - latest=auto - tags: | - type=edge,branch=main - type=pep440,pattern={{raw}} - type=pep440,pattern=v{{major}}.{{minor}} - type=ref,event=pr - - uses: docker/build-push-action@v4 - with: - context: . - platforms: linux/amd64,linux/arm64 - provenance: false - builder: ${{ steps.buildx.outputs.name }} - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml new file mode 100644 index 00000000000..1790d5c84fa --- /dev/null +++ b/.github/workflows/build-nightly.yml @@ -0,0 +1,43 @@ +name: Build nightly container image +on: + workflow_dispatch: + schedule: + - cron: '0 2 * * *' # run at 2 AM UTC + +permissions: + contents: read + packages: write + +jobs: + compute-suffix: + runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' + steps: + - id: version_vars + env: + TZ: Etc/UTC + run: | + echo mastodon_version_prerelease=nightly.$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT + outputs: + prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }} + + build-image: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: true + cache: false + push_to_images: | + tootsuite/mastodon + ghcr.io/mastodon/mastodon + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=auto + 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 00000000000..1f647e2a141 --- /dev/null +++ b/.github/workflows/build-push-pr.yml @@ -0,0 +1,41 @@ +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: + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: true + push_to_images: | + ghcr.io/mastodon/mastodon + 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 00000000000..3b82eef9d89 --- /dev/null +++ b/.github/workflows/build-releases.yml @@ -0,0 +1,29 @@ +name: Build container release images +on: + push: + tags: + - '*' + +permissions: + contents: read + packages: write + +jobs: + build-image: + uses: ./.github/workflows/build-container-image.yml + with: + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: true + push_to_images: | + tootsuite/mastodon + ghcr.io/mastodon/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 diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml new file mode 100644 index 00000000000..bbc31598c75 --- /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 aa8f1f58445..ceb385933b2 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -17,18 +17,18 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Install system dependencies + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Check for missing strings in English JSON 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 + yarn i18n:extract --throws + git diff --exit-code - name: Check locale file normalization run: bundle exec i18n-tasks check-normalized @@ -36,7 +36,7 @@ jobs: - name: Check for unused strings run: bundle exec i18n-tasks unused - - name: Check for missing strings in English + - name: Check for missing strings in English YML run: | bundle exec i18n-tasks add-missing -l en git diff --exit-code diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8534501d4ef..3b40c3fd07b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml new file mode 100644 index 00000000000..d3988d2f1a3 --- /dev/null +++ b/.github/workflows/crowdin-download.yml @@ -0,0 +1,71 @@ +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 == 'mastodon/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: + 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 00000000000..705af12c025 --- /dev/null +++ b/.github/workflows/crowdin-upload.yml @@ -0,0 +1,35 @@ +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: + 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/haml-lint-problem-matcher.json b/.github/workflows/haml-lint-problem-matcher.json new file mode 100644 index 00000000000..3523ea29515 --- /dev/null +++ b/.github/workflows/haml-lint-problem-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "haml-lint", + "severity": "warning", + "pattern": [ + { + "regexp": "^(.*):(\\d+)\\s\\[W]\\s(.*):\\s(.*)$", + "file": 1, + "line": 2, + "code": 3, + "message": 4 + } + ] + } + ] +} diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index e13d227bdbf..7229bec5822 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -3,6 +3,7 @@ on: push: branches-ignore: - 'dependabot/**' + - 'renovate/**' paths: - 'package.json' - 'yarn.lock' @@ -32,20 +33,14 @@ 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 - run: echo "::add-matcher::.github/stylelint-matcher.json" - name: Stylelint - run: yarn test:lint:sass + run: yarn lint:sass diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml new file mode 100644 index 00000000000..8dcab845ee0 --- /dev/null +++ b/.github/workflows/lint-haml.yml @@ -0,0 +1,39 @@ +name: Haml Linting +on: + push: + branches-ignore: + - 'dependabot/**' + - 'renovate/**' + paths: + - '.github/workflows/haml-lint-problem-matcher.json' + - '.github/workflows/lint-haml.yml' + - '.haml-lint*.yml' + - '.rubocop*.yml' + - '.ruby-version' + - '**/*.haml' + - 'Gemfile*' + + pull_request: + paths: + - '.github/workflows/haml-lint-problem-matcher.json' + - '.github/workflows/lint-haml.yml' + - '.haml-lint*.yml' + - '.rubocop*.yml' + - '.ruby-version' + - '**/*.haml' + - 'Gemfile*' + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + + - name: Run haml-lint + run: | + echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json" + bundle exec haml-lint diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 44929f63db6..1c1ecc2b220 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -3,25 +3,32 @@ on: push: branches-ignore: - 'dependabot/**' + - 'renovate/**' paths: - 'package.json' - 'yarn.lock' + - 'tsconfig.json' - '.nvmrc' - '.prettier*' - '.eslint*' - '**/*.js' - '**/*.jsx' + - '**/*.ts' + - '**/*.tsx' - '.github/workflows/lint-js.yml' pull_request: paths: - 'package.json' - 'yarn.lock' + - 'tsconfig.json' - '.nvmrc' - '.prettier*' - '.eslint*' - '**/*.js' - '**/*.jsx' + - '**/*.ts' + - '**/*.tsx' - '.github/workflows/lint-js.yml' jobs: @@ -30,16 +37,13 @@ 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 test:lint:js + run: yarn lint:js --max-warnings 0 + + - name: Typecheck + run: yarn typecheck diff --git a/.github/workflows/lint-json.yml b/.github/workflows/lint-json.yml index 98f101ad95a..7796bf92c4a 100644 --- a/.github/workflows/lint-json.yml +++ b/.github/workflows/lint-json.yml @@ -3,6 +3,7 @@ on: push: branches-ignore: - 'dependabot/**' + - 'renovate/**' paths: - 'package.json' - 'yarn.lock' @@ -28,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 prettier --check "**/*.json" + run: yarn lint:json diff --git a/.github/workflows/lint-md.yml b/.github/workflows/lint-md.yml index 6f76dd60c2f..51c59937a30 100644 --- a/.github/workflows/lint-md.yml +++ b/.github/workflows/lint-md.yml @@ -3,8 +3,10 @@ on: push: branches-ignore: - 'dependabot/**' + - 'renovate/**' paths: - '.github/workflows/lint-md.yml' + - '.nvmrc' - '.prettier*' - '**/*.md' - '!AUTHORS.md' @@ -14,6 +16,7 @@ on: pull_request: paths: - '.github/workflows/lint-md.yml' + - '.nvmrc' - '.prettier*' - '**/*.md' - '!AUTHORS.md' @@ -26,15 +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 - - - name: Install all yarn packages - run: yarn --frozen-lockfile + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript - name: Prettier - run: yarn prettier --check "**/*.md" + run: yarn lint:md diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index de54fe9ae5a..411b323486a 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -3,11 +3,12 @@ on: push: branches-ignore: - 'dependabot/**' + - 'renovate/**' paths: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' - - '.bundler-audit.yml' + - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' - '.github/workflows/lint-ruby.yml' @@ -17,7 +18,7 @@ on: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' - - '.bundler-audit.yml' + - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' - '.github/workflows/lint-ruby.yml' @@ -28,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 @@ -45,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 6f79babcfd4..908bdef5ccf 100644 --- a/.github/workflows/lint-yml.yml +++ b/.github/workflows/lint-yml.yml @@ -3,6 +3,7 @@ on: push: branches-ignore: - 'dependabot/**' + - 'renovate/**' paths: - 'package.json' - 'yarn.lock' @@ -30,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 prettier --check "**/*.{yml,yaml}" + run: yarn lint:yml diff --git a/.github/workflows/rebase-needed.yml b/.github/workflows/rebase-needed.yml index 99b224ec607..06d835c090e 100644 --- a/.github/workflows/rebase-needed.yml +++ b/.github/workflows/rebase-needed.yml @@ -1,9 +1,8 @@ name: PR Needs Rebase on: - push: - pull_request_target: - types: [synchronize] + schedule: + - cron: '0 * * * *' permissions: pull-requests: write @@ -24,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 00000000000..778e341771e --- /dev/null +++ b/.github/workflows/test-image-build.yml @@ -0,0 +1,21 @@ +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 +permissions: + contents: read + +jobs: + build-image: + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + uses: ./.github/workflows/build-container-image.yml + with: + platforms: linux/amd64 # Testing only on native platform so it is performant diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index 6a1cacb3f09..79622b6c1f6 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -3,12 +3,15 @@ on: push: branches-ignore: - 'dependabot/**' + - 'renovate/**' paths: - 'package.json' - 'yarn.lock' - '.nvmrc' - '**/*.js' - '**/*.jsx' + - '**/*.ts' + - '**/*.tsx' - '**/*.snap' - '.github/workflows/test-js.yml' @@ -19,6 +22,8 @@ on: - '.nvmrc' - '**/*.js' - '**/*.jsx' + - '**/*.ts' + - '**/*.tsx' - '**/*.snap' - '.github/workflows/test-js.yml' @@ -28,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 test:jest --reporters github-actions summary + 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 8f070697cad..5dca8e376da 100644 --- a/.github/workflows/test-migrations-one-step.yml +++ b/.github/workflows/test-migrations-one-step.yml @@ -3,6 +3,7 @@ on: push: branches-ignore: - 'dependabot/**' + - 'renovate/**' pull_request: jobs: @@ -16,16 +17,24 @@ jobs: - id: skip_check uses: fkirc/skip-duplicate-actions@v5 with: - paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations-one-step.yml"]' + paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations-one-step.yml", "lib/tasks/tests.rake"]' test: runs-on: ubuntu-latest needs: pre_job if: needs.pre_job.outputs.should_skip != 'true' + strategy: + fail-fast: false + + matrix: + postgres: + - 14-alpine + - 15-alpine + services: postgres: - image: postgres:14.5 + image: postgres:${{ matrix.postgres}} env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres @@ -38,7 +47,7 @@ jobs: - 5432:5432 redis: - image: redis:7.0 + image: redis:7-alpine options: >- --health-cmd "redis-cli ping" --health-interval 10s @@ -61,16 +70,10 @@ jobs: BUNDLE_RETRY: 3 steps: - - 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 bundler cache - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby - name: Create database run: './bin/rails db:create' diff --git a/.github/workflows/test-migrations-two-step.yml b/.github/workflows/test-migrations-two-step.yml index 2fdce80254c..59485d285df 100644 --- a/.github/workflows/test-migrations-two-step.yml +++ b/.github/workflows/test-migrations-two-step.yml @@ -3,6 +3,7 @@ on: push: branches-ignore: - 'dependabot/**' + - 'renovate/**' pull_request: jobs: @@ -16,16 +17,24 @@ jobs: - id: skip_check uses: fkirc/skip-duplicate-actions@v5 with: - paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations-two-step.yml"]' + paths: '["Gemfile*", ".ruby-version", "**/*.rb", ".github/workflows/test-migrations-two-step.yml", "lib/tasks/tests.rake"]' test: runs-on: ubuntu-latest needs: pre_job if: needs.pre_job.outputs.should_skip != 'true' + strategy: + fail-fast: false + + matrix: + postgres: + - 14-alpine + - 15-alpine + services: postgres: - image: postgres:14.5 + image: postgres:${{ matrix.postgres}} env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres @@ -37,7 +46,7 @@ jobs: ports: - 5432:5432 redis: - image: redis:7.0 + image: redis:7-alpine options: >- --health-cmd "redis-cli ping" --health-interval 10s @@ -60,16 +69,10 @@ jobs: BUNDLE_RETRY: 3 steps: - - 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 bundler cache - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby - name: Create database run: './bin/rails db:create' diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml new file mode 100644 index 00000000000..117e751454c --- /dev/null +++ b/.github/workflows/test-ruby.yml @@ -0,0 +1,324 @@ +name: Ruby Testing + +on: + push: + branches-ignore: + - 'dependabot/**' + - 'renovate/**' + pull_request: + +env: + BUNDLE_CLEAN: true + BUNDLE_FROZEN: true + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + mode: + - production + - test + env: + RAILS_ENV: ${{ matrix.mode }} + BUNDLE_WITH: ${{ matrix.mode }} + OTP_SECRET: precompile_placeholder + SECRET_KEY_BASE: precompile_placeholder + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + with: + onlyProduction: 'true' + + - 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 + if: matrix.mode == 'test' + with: + path: |- + ./public/assets + ./public/packs-test + name: ${{ github.sha }} + retention-days: 0 + + test: + 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 + 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 + matrix: + ruby-version: + - '3.0' + - '3.1' + - '.ruby-version' + ci_job: + - 1 + - 2 + - 3 + - 4 + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v3 + with: + path: './public' + name: ${{ github.sha }} + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + with: + ruby-version: ${{ matrix.ruby-version}} + additional-system-dependencies: ffmpeg imagemagick libpam-dev + + - name: Load database schema + run: './bin/rails db:create db:schema:load db:seed' + + - run: bundle exec rake rspec_chunked + + test-e2e: + name: End to End testing + runs-on: ubuntu-latest + + needs: + - build + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + DISABLE_SIMPLECOV: true + RAILS_ENV: test + BUNDLE_WITH: test + + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.0' + - '3.1' + - '.ruby-version' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v3 + with: + path: './public' + name: ${{ github.sha }} + + - name: 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@v3 + if: failure() + with: + name: e2e-logs-${{ matrix.ruby-version }} + path: log/ + + - name: Archive test screenshots + uses: actions/upload-artifact@v3 + if: failure() + with: + name: e2e-screenshots + path: tmp/screenshots/ + + test-search: + name: Testing search + runs-on: ubuntu-latest + + needs: + - build + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + search: + image: ${{ matrix.search-image }} + env: + discovery.type: single-node + xpack.security.enabled: false + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + ports: + - 9200:9200 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + DISABLE_SIMPLECOV: true + RAILS_ENV: test + BUNDLE_WITH: test + ES_ENABLED: true + ES_HOST: localhost + ES_PORT: 9200 + + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.0' + - '3.1' + - '.ruby-version' + search-image: + - docker.elastic.co/elasticsearch/elasticsearch:7.17.13 + include: + - ruby-version: '.ruby-version' + search-image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v3 + with: + path: './public' + name: ${{ github.sha }} + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + with: + ruby-version: ${{ matrix.ruby-version}} + additional-system-dependencies: ffmpeg imagemagick + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Load database schema + run: './bin/rails db:create db:schema:load db:seed' + + - run: bundle exec rake spec:search + + - name: Archive logs + uses: actions/upload-artifact@v3 + if: failure() + with: + name: test-search-logs-${{ matrix.ruby-version }} + path: log/ + + - name: Archive test screenshots + uses: actions/upload-artifact@v3 + if: failure() + with: + name: test-search-screenshots + path: tmp/screenshots/ diff --git a/.gitignore b/.gitignore index 2bc8b18c8f0..cb442609a1a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,9 +31,6 @@ # Ignore Vagrant files .vagrant/ -# Ignore Capistrano customizations -/config/deploy/* - # Ignore IDE files .vscode/ .idea/ diff --git a/.haml-lint.yml b/.haml-lint.yml index 7853d81d7ce..d1ed30b260c 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -1,108 +1,14 @@ -# Whether to ignore frontmatter at the beginning of HAML documents for -# frameworks such as Jekyll/Middleman -skip_frontmatter: false +inherits_from: .haml-lint_todo.yml exclude: - 'vendor/**/*' - - 'spec/**/*' - - 'lib/templates/**/*' - - 'app/views/kaminari/**/*' + - lib/templates/haml/scaffold/_form.html.haml + +require: + - ./lib/linter/haml_middle_dot.rb linters: AltText: - enabled: false - - ClassAttributeWithStaticValue: enabled: true - - ClassesBeforeIds: - enabled: true - - ConsecutiveComments: - enabled: true - - ConsecutiveSilentScripts: - enabled: true - max_consecutive: 2 - - EmptyObjectReference: - enabled: true - - EmptyScript: - enabled: true - - FinalNewline: - enabled: true - present: true - - HtmlAttributes: - enabled: true - - ImplicitDiv: - enabled: true - - LeadingCommentSpace: - enabled: true - - LineLength: - enabled: false - max: 80 - - MultilinePipe: - enabled: true - - MultilineScript: - enabled: true - - ObjectReferenceAttributes: - enabled: true - - RuboCop: - enabled: true - # These cops are incredibly noisy when it comes to HAML templates, so we - # ignore them. - ignored_cops: - - Lint/BlockAlignment - - Lint/EndAlignment - - Lint/Void - - Metrics/BlockLength - - Metrics/LineLength - - Style/AlignParameters - - Style/BlockNesting - - Style/ElseAlignment - - Style/EndOfLine - - Style/FileName - - Style/FinalNewline - - Style/FrozenStringLiteralComment - - Style/IfUnlessModifier - - Style/IndentationWidth - - Style/Next - - Style/TrailingBlankLines - - Style/TrailingWhitespace - - Style/WhileUntilModifier - - RubyComments: - enabled: true - - SpaceBeforeScript: - enabled: true - - SpaceInsideHashAttributes: - enabled: true - style: space - - Indentation: - enabled: true - character: space # or tab - - TagName: - enabled: true - - TrailingWhitespace: - enabled: true - - UnnecessaryInterpolation: - enabled: true - - UnnecessaryStringOutput: + MiddleDot: enabled: true diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml new file mode 100644 index 00000000000..d5f6912a6ce --- /dev/null +++ b/.haml-lint_todo.yml @@ -0,0 +1,77 @@ +# This configuration was generated by +# `haml-lint --auto-gen-config` +# on 2023-10-23 10:16:00 -0400 using Haml-Lint version 0.51.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: 944 + 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/_session.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: 45 + RuboCop: + exclude: + - 'app/views/admin/accounts/_account.html.haml' + - 'app/views/admin/accounts/_buttons.html.haml' + - 'app/views/admin/accounts/_local_account.html.haml' + - 'app/views/admin/accounts/_remote_account.html.haml' + - 'app/views/admin/accounts/index.html.haml' + - 'app/views/admin/accounts/show.html.haml' + - 'app/views/admin/custom_emojis/index.html.haml' + - 'app/views/admin/dashboard/index.html.haml' + - 'app/views/admin/domain_blocks/confirm_suspension.html.haml' + - 'app/views/admin/follow_recommendations/show.html.haml' + - 'app/views/admin/invites/_invite.html.haml' + - 'app/views/admin/invites/index.html.haml' + - 'app/views/admin/ip_blocks/index.html.haml' + - 'app/views/admin/reports/_status.html.haml' + - 'app/views/admin/reports/show.html.haml' + - 'app/views/admin/roles/_form.html.haml' + - 'app/views/admin/software_updates/index.html.haml' + - 'app/views/admin/status_edits/_status_edit.html.haml' + - 'app/views/admin/statuses/index.html.haml' + - 'app/views/admin/tags/show.html.haml' + - 'app/views/admin/trends/tags/_tag.html.haml' + - 'app/views/auth/registrations/_session.html.haml' + - 'app/views/auth/registrations/new.html.haml' + - 'app/views/auth/sessions/two_factor.html.haml' + - 'app/views/auth/shared/_progress.html.haml' + - 'app/views/disputes/strikes/_card.html.haml' + - 'app/views/filters/statuses/index.html.haml' + - 'app/views/invites/_invite.html.haml' + - 'app/views/layouts/application.html.haml' + - 'app/views/layouts/error.html.haml' + - 'app/views/statuses/_detailed_status.html.haml' + - 'app/views/statuses/_og_image.html.haml' + - 'app/views/statuses/_simple_status.html.haml' + - 'app/views/statuses_cleanup/show.html.haml' + - 'app/views/user_mailer/warning.html.haml' + + # Offense count: 2 + IdNames: + exclude: + - 'app/views/oauth/authorizations/error.html.haml' + - 'app/views/shared/_error_messages.html.haml' diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000000..d2ae35e84b0 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn lint-staged diff --git a/.nvmrc b/.nvmrc index 030fcd56bf7..fa69d015bdb 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.19 +20.8 diff --git a/.prettierignore b/.prettierignore index 15e5f59944b..305f0fd753d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,9 +31,6 @@ # Ignore Vagrant files .vagrant/ -# Ignore Capistrano customizations -/config/deploy/* - # Ignore IDE files .vscode/ .idea/ @@ -61,7 +58,7 @@ docker-compose.override.yml /app/javascript/mastodon/features/emoji/emoji_map.json # Ignore locale files -/app/javascript/mastodon/locales +/app/javascript/mastodon/locales/*.json /config/locales # Ignore vendored CSS reset @@ -69,6 +66,7 @@ app/javascript/styles/mastodon/reset.scss # Ignore Javascript pending https://github.com/mastodon/mastodon/pull/23631 *.js +*.jsx # Ignore HTML till cleaned and included in CI *.html diff --git a/.prettierrc.js b/.prettierrc.js index 1d70813d51a..af39b253f60 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,3 +1,4 @@ module.exports = { - singleQuote: true + singleQuote: true, + jsxSingleQuote: true } diff --git a/.profile b/.profile index c6d57b609d3..f4826ea3033 100644 --- a/.profile +++ b/.profile @@ -1 +1 @@ -LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/app/.apt/lib/x86_64-linux-gnu:/app/.apt/usr/lib/x86_64-linux-gnu/mesa:/app/.apt/usr/lib/x86_64-linux-gnu/pulseaudio +LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/app/.apt/lib/x86_64-linux-gnu:/app/.apt/usr/lib/x86_64-linux-gnu/mesa:/app/.apt/usr/lib/x86_64-linux-gnu/pulseaudio:/app/.apt/usr/lib/x86_64-linux-gnu/openblas-pthread diff --git a/.rubocop.yml b/.rubocop.yml index 27d778edfb7..64ec766b223 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,7 @@ +# Can be removed once all rules are addressed or moved to this file as documented overrides inherit_from: .rubocop_todo.yml +# Used for merging with exclude lists with .rubocop_todo.yml inherit_mode: merge: - Exclude @@ -8,118 +10,188 @@ require: - rubocop-rails - rubocop-rspec - rubocop-performance + - rubocop-capybara + - ./lib/linter/rubocop_middle_dot AllCops: - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.0 # Set to minimum supported version of CI DisplayCopNames: true DisplayStyleGuide: true ExtraDetails: true UseCache: true CacheRootDirectory: tmp - NewCops: enable + NewCops: enable # Opt-in to newly added rules Exclude: - db/schema.rb - - 'app/views/**/*' - - 'config/**/*' - 'bin/*' - - 'Rakefile' - 'node_modules/**/*' - 'Vagrantfile' - 'vendor/**/*' - - 'lib/json_ld/*' + - 'lib/json_ld/*' # Generated files + - 'lib/mastodon/migration_helpers.rb' # Vendored from GitLab - 'lib/templates/**/*' +# Reason: Prefer Hashes without extreme indentation +# https://docs.rubocop.org/rubocop/cops_layout.html#layoutfirsthashelementindentation Layout/FirstHashElementIndentation: EnforcedStyle: consistent +# Reason: Currently disabled in .rubocop_todo.yml +# https://docs.rubocop.org/rubocop/cops_layout.html#layoutlinelength Layout/LineLength: - Max: 140 # RuboCop default 120 - AllowedPatterns: - # Allow comments to be long lines - - !ruby/regexp / \# .*$/ - - !ruby/regexp /^\# .*$/ - Exclude: - - lib/**/*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 Lint/UselessAccessModifier: ContextCreatingMethods: - class_methods -Metrics/AbcSize: - Max: 34 # RuboCop default 17 - Exclude: - - 'lib/**/*cli*.rb' - - db/*migrate/**/* +## Disable most Metrics/*Length cops +# Reason: those are often triggered and force significant refactors when this happend +# but the team feel they are not really improving the code quality. +# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocklength Metrics/BlockLength: - Max: 55 # Default 25 - CountAsOne: [array, heredoc] - Exclude: - - 'lib/mastodon/*_cli.rb' - -Metrics/BlockNesting: - Exclude: - - 'lib/mastodon/*_cli.rb' + Enabled: false +# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsclasslength Metrics/ClassLength: - Max: 500 # Default 100 - CountAsOne: [array, heredoc] - Exclude: - - 'lib/mastodon/*_cli.rb' + Enabled: false -Metrics/CyclomaticComplexity: - Max: 12 # Default 7 +# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmethodlength +Metrics/MethodLength: + Enabled: false + +# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmodulelength +Metrics/ModuleLength: + Enabled: false + +## End Disable Metrics/*Length cops + +# Reason: Currently disabled in .rubocop_todo.yml +# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsabcsize +Metrics/AbcSize: Exclude: - - lib/mastodon/*cli*.rb + - 'lib/mastodon/cli/*.rb' - db/*migrate/**/* -Metrics/MethodLength: - Max: 25 # RuboCop default 10 - CountAsOne: [array, heredoc] +# Reason: Currently disabled in .rubocop_todo.yml +# https://docs.rubocop.org/rubocop/cops_metrics.html#metricscyclomaticcomplexity +Metrics/CyclomaticComplexity: Exclude: - - 'lib/mastodon/*_cli.rb' + - lib/mastodon/cli/*.rb + - db/*migrate/**/* -Metrics/ModuleLength: - Max: 200 # Default 100 - CountAsOne: [array, heredoc] +# Reason: +# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsparameterlists +Metrics/ParameterLists: + CountKeywordArgs: false -Metrics/PerceivedComplexity: - Max: 16 # RuboCop default 8 +# Reason: Prevailing style is argument file paths +# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsfilepath +Rails/FilePath: + EnforcedStyle: arguments +# Reason: Prevailing style uses numeric status codes, matches RSpec/Rails/HttpStatus +# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railshttpstatus Rails/HttpStatus: EnforcedStyle: numeric +# Reason: Allowed in `tootctl` CLI code and in boot ENV checker +# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsexit Rails/Exit: Exclude: - - 'lib/mastodon/*_cli.rb' - - 'lib/mastodon/cli_helper.rb' - - 'lib/cli.rb' + - 'config/boot.rb' + - 'lib/mastodon/cli/*.rb' +# Reason: Some single letter camel case files shouldn't be split +# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecfilepath +RSpec/FilePath: + CustomTransform: + ActivityPub: activitypub # Ignore the snake_case due to the amount of files to rename + 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 + +# Reason: +# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnamedsubject +RSpec/NamedSubject: + EnforcedStyle: named_only + +# Reason: Prevailing style choice +# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnottonot RSpec/NotToNot: EnforcedStyle: to_not +# Reason: Prevailing style uses numeric status codes, matches Rails/HttpStatus +# https://docs.rubocop.org/rubocop-rspec/cops_rspec_rails.html#rspecrailshttpstatus RSpec/Rails/HttpStatus: EnforcedStyle: numeric +# Reason: +# https://docs.rubocop.org/rubocop/cops_style.html#styleclassandmodulechildren +Style/ClassAndModuleChildren: + Enabled: false + +# Reason: Classes mostly self-document with their names +# https://docs.rubocop.org/rubocop/cops_style.html#styledocumentation +Style/Documentation: + Enabled: false + +# Reason: Enforce modern Ruby style +# https://docs.rubocop.org/rubocop/cops_style.html#stylehashsyntax Style/HashSyntax: EnforcedStyle: ruby19_no_mixed_keys +# Reason: +# https://docs.rubocop.org/rubocop/cops_style.html#stylenumericliterals Style/NumericLiterals: AllowedPatterns: - \d{4}_\d{2}_\d{2}_\d{6} # For DB migration date version number readability +# Reason: +# https://docs.rubocop.org/rubocop/cops_style.html#stylepercentliteraldelimiters Style/PercentLiteralDelimiters: PreferredDelimiters: '%i': '()' '%w': '()' +# Reason: Prefer less indentation in conditional assignments +# https://docs.rubocop.org/rubocop/cops_style.html#styleredundantbegin +Style/RedundantBegin: + Enabled: false + +# Reason: Overridden to reduce implicit StandardError rescues +# https://docs.rubocop.org/rubocop/cops_style.html#stylerescuestandarderror 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: + Enabled: false + +# Reason: +# https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainarrayliteral Style/TrailingCommaInArrayLiteral: EnforcedStyleForMultiline: 'comma' +# Reason: +# https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainhashliteral Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: 'comma' + +Style/MiddleDot: + Enabled: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index bb214a70bbc..9609923434b 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,12 +1,11 @@ # This configuration was generated by -# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit` -# on 2023-02-19 06:22:09 UTC using RuboCop version 1.45.1. +# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp` +# using RuboCop version 1.57.1. # 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 # versions of RuboCop, may require this file to be generated again. -# Offense count: 15 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. # Include: **/*.gemfile, **/Gemfile, **/gems.rb @@ -14,376 +13,71 @@ Bundler/OrderedGems: Exclude: - 'Gemfile' -# Offense count: 581 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. +# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. # URISchemes: http, https Layout/LineLength: - Enabled: false - -# Offense count: 14 -# Configuration parameters: AllowedMethods, AllowedPatterns. -Lint/AmbiguousBlockAssociation: Exclude: - - 'spec/controllers/admin/account_moderation_notes_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/otp_authentication_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb' - - 'spec/services/activitypub/process_status_update_service_spec.rb' - - 'spec/services/post_status_service_spec.rb' - - 'spec/services/suspend_account_service_spec.rb' - - 'spec/services/unsuspend_account_service_spec.rb' - - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb' + - 'app/models/account.rb' -# Offense count: 15 -# Configuration parameters: AllowedMethods. -# AllowedMethods: enums -Lint/ConstantDefinitionInBlock: - Exclude: - - 'spec/controllers/api/base_controller_spec.rb' - - 'spec/controllers/application_controller_spec.rb' - - 'spec/controllers/concerns/accountable_concern_spec.rb' - - 'spec/controllers/concerns/signature_verification_spec.rb' - - 'spec/lib/activitypub/adapter_spec.rb' - - 'spec/lib/connection_pool/shared_connection_pool_spec.rb' - - 'spec/lib/connection_pool/shared_timed_stack_spec.rb' - - 'spec/lib/settings/extend_spec.rb' - - 'spec/models/concerns/remotable_spec.rb' - -# Offense count: 5 -# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches. -Lint/DuplicateBranch: - Exclude: - - 'app/lib/permalink_redirector.rb' - - 'app/models/account_statuses_filter.rb' - - 'app/validators/email_mx_validator.rb' - - 'app/validators/vote_validator.rb' - - 'lib/mastodon/maintenance_cli.rb' - -# Offense count: 42 # Configuration parameters: AllowComments, AllowEmptyLambdas. Lint/EmptyBlock: Exclude: - 'spec/controllers/api/v2/search_controller_spec.rb' - - 'spec/controllers/application_controller_spec.rb' - 'spec/fabricators/access_token_fabricator.rb' - 'spec/fabricators/conversation_fabricator.rb' - - 'spec/fabricators/conversation_mute_fabricator.rb' - - 'spec/fabricators/import_fabricator.rb' - - 'spec/fabricators/setting_fabricator.rb' - 'spec/fabricators/system_key_fabricator.rb' - - 'spec/fabricators/web_setting_fabricator.rb' - - 'spec/helpers/admin/action_log_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/one_time_key_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' -# Offense count: 1 -# Configuration parameters: AllowComments. -Lint/EmptyClass: - Exclude: - - 'spec/controllers/api/base_controller_spec.rb' - -# Offense count: 1 -# This cop supports unsafe autocorrection (--autocorrect-all). -Lint/NonDeterministicRequireOrder: - Exclude: - - 'spec/rails_helper.rb' - -# Offense count: 1 Lint/NonLocalExitFromIterator: Exclude: - 'app/helpers/jsonld_helper.rb' -# Offense count: 2 # This cop supports unsafe autocorrection (--autocorrect-all). Lint/OrAssignmentToConstant: Exclude: - 'lib/sanitize_ext/sanitize_config.rb' -# Offense count: 33 -Lint/UselessAssignment: +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: IgnoreEmptyBlocks, AllowUnusedKeywordArguments. +Lint/UnusedBlockArgument: Exclude: - - 'app/services/activitypub/process_status_update_service.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' + - 'config/initializers/content_security_policy.rb' + - 'config/initializers/doorkeeper.rb' + - 'config/initializers/paperclip.rb' + - 'config/initializers/simple_form.rb' -# Offense count: 3 -# Configuration parameters: CheckForMethodsWithNoSideEffects. -Lint/Void: - Exclude: - - 'spec/services/resolve_account_service_spec.rb' - -# Offense count: 66 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: - Enabled: false + Max: 144 -# Offense count: 10 -# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode. -# AllowedMethods: refine -Metrics/BlockLength: - Exclude: - - 'app/models/concerns/account_interactions.rb' - - 'db/post_migrate/20221101190723_backfill_admin_action_logs.rb' - - 'db/post_migrate/20221206114142_backfill_admin_action_logs_again.rb' - - 'lib/tasks/branding.rake' - - 'lib/tasks/mastodon.rake' - - 'lib/tasks/repo.rake' - - 'lib/tasks/tests.rake' - -# Offense count: 1 # Configuration parameters: CountBlocks, Max. Metrics/BlockNesting: Exclude: - 'lib/tasks/mastodon.rake' -# Offense count: 39 # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/CyclomaticComplexity: - Enabled: false + Max: 25 -# Offense count: 35 -# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. -Metrics/MethodLength: - Enabled: false - -# Offense count: 1 -# Configuration parameters: CountComments, Max, CountAsOne. -Metrics/ModuleLength: - Exclude: - - 'app/models/concerns/account_interactions.rb' - -# Offense count: 5 -# Configuration parameters: Max, CountKeywordArgs, MaxOptionalParameters. -Metrics/ParameterLists: - Exclude: - - 'app/models/concerns/account_interactions.rb' - - 'app/services/activitypub/fetch_remote_account_service.rb' - - 'app/services/activitypub/fetch_remote_actor_service.rb' - - 'app/services/activitypub/fetch_remote_status_service.rb' - -# Offense count: 16 -# Configuration parameters: AllowedMethods, AllowedPatterns, Max. +# Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/PerceivedComplexity: - Exclude: - - 'app/helpers/jsonld_helper.rb' - - 'app/lib/feed_manager.rb' - - 'app/lib/status_cache_hydrator.rb' - - 'app/lib/user_settings_decorator.rb' - - 'app/models/trends/links.rb' - - 'app/services/activitypub/fetch_remote_key_service.rb' - - 'app/services/activitypub/fetch_remote_status_service.rb' - - 'app/services/activitypub/process_account_service.rb' - - 'app/services/fetch_link_card_service.rb' - - 'app/services/fetch_oembed_service.rb' - - 'app/services/process_mentions_service.rb' - - 'app/services/resolve_account_service.rb' - - 'lib/mastodon/accounts_cli.rb' - - 'lib/mastodon/domains_cli.rb' - - 'lib/mastodon/maintenance_cli.rb' + Max: 27 -# Offense count: 1 -Naming/AccessorMethodName: +Performance/MapMethodChain: Exclude: - - 'app/controllers/auth/sessions_controller.rb' + - 'app/models/feed.rb' + - 'lib/mastodon/cli/maintenance.rb' + - 'spec/services/bulk_import_service_spec.rb' + - 'spec/services/import_service_spec.rb' -# Offense count: 7 -# 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' - -# Offense count: 50 -# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. -# SupportedStyles: snake_case, normalcase, non_integer -# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339 -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/controllers/activitypub/followers_synchronizations_controller_spec.rb' - - 'spec/lib/feed_manager_spec.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/concerns/account_interactions_spec.rb' - - 'spec/models/custom_emoji_filter_spec.rb' - - 'spec/models/domain_block_spec.rb' - - 'spec/models/user_spec.rb' - - 'spec/services/activitypub/fetch_featured_collection_service_spec.rb' - -# Offense count: 12 -# Configuration parameters: MinSize. -Performance/CollectionLiteralInLoop: - Exclude: - - 'app/models/admin/appeal_filter.rb' - - 'app/models/admin/status_filter.rb' - - 'app/models/relationship_filter.rb' - - 'app/models/trends/preview_card_filter.rb' - - 'app/models/trends/status_filter.rb' - - 'app/presenters/status_relationships_presenter.rb' - - 'app/services/fetch_resource_service.rb' - - 'app/services/suspend_account_service.rb' - - 'app/services/unsuspend_account_service.rb' - - 'lib/mastodon/media_cli.rb' - -# Offense count: 4 -# This cop supports unsafe autocorrection (--autocorrect-all). -Performance/Count: - Exclude: - - 'app/lib/importer/accounts_index_importer.rb' - - 'app/lib/importer/tags_index_importer.rb' - -# Offense count: 10 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: SafeMultiline. -Performance/DeletePrefix: - Exclude: - - 'app/controllers/authorize_interactions_controller.rb' - - 'app/controllers/concerns/signature_verification.rb' - - 'app/controllers/intents_controller.rb' - - 'app/lib/activitypub/case_transform.rb' - - 'app/lib/permalink_redirector.rb' - - 'app/lib/webfinger_resource.rb' - - 'app/services/activitypub/fetch_remote_actor_service.rb' - - 'app/services/backup_service.rb' - - 'app/services/resolve_account_service.rb' - - 'app/services/tag_search_service.rb' - -# Offense count: 1 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: SafeMultiline. -Performance/DeleteSuffix: - Exclude: - - 'lib/tasks/repo.rake' - -# Offense count: 19 -# This cop supports unsafe autocorrection (--autocorrect-all). -Performance/MapCompact: - Exclude: - - 'app/lib/admin/metrics/dimension.rb' - - 'app/lib/admin/metrics/measure.rb' - - 'app/lib/feed_manager.rb' - - 'app/models/account.rb' - - 'app/models/account_statuses_cleanup_policy.rb' - - 'app/models/account_suggestions/setting_source.rb' - - 'app/models/account_suggestions/source.rb' - - 'app/models/follow_recommendation_filter.rb' - - 'app/models/notification.rb' - - 'app/models/user_role.rb' - - 'app/models/webhook.rb' - - 'app/services/process_mentions_service.rb' - - 'app/validators/existing_username_validator.rb' - - 'db/migrate/20200407202420_migrate_unavailable_inboxes.rb' - - 'spec/presenters/status_relationships_presenter_spec.rb' - -# Offense count: 7 -Performance/MethodObjectAsBlock: - Exclude: - - 'app/models/account_suggestions/source.rb' - - 'spec/models/export_spec.rb' - -# Offense count: 1 -# This cop supports unsafe autocorrection (--autocorrect-all). -Performance/RedundantEqualityComparisonBlock: - Exclude: - - 'spec/requests/link_headers_spec.rb' - -# Offense count: 1 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: SafeMultiline. -Performance/StartWith: - Exclude: - - 'app/lib/extractor.rb' - -# Offense count: 4 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: OnlySumOrWithInitialValue. -Performance/Sum: - Exclude: - - 'app/lib/activity_tracker.rb' - - 'app/models/trends/history.rb' - - 'app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb' - - 'lib/paperclip/color_extractor.rb' - -# Offense count: 15 -# This cop supports unsafe autocorrection (--autocorrect-all). -Performance/TimesMap: - Exclude: - - 'spec/controllers/api/v1/blocks_controller_spec.rb' - - 'spec/controllers/api/v1/mutes_controller_spec.rb' - - 'spec/lib/feed_manager_spec.rb' - - 'spec/lib/request_pool_spec.rb' - - 'spec/models/account_spec.rb' - -# Offense count: 4 -# 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' - -# Offense count: 27 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' @@ -392,337 +86,14 @@ RSpec/AnyInstance: - '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' -# Offense count: 1 -RSpec/BeforeAfterAll: - Exclude: - - 'spec/requests/localization_spec.rb' - -# Offense count: 558 -# Configuration parameters: Prefixes, AllowedPatterns. -# Prefixes: when, with, without -RSpec/ContextWording: - Exclude: - - 'spec/config/initializers/rack_attack_spec.rb' - - 'spec/controllers/accounts_controller_spec.rb' - - 'spec/controllers/activitypub/collections_controller_spec.rb' - - 'spec/controllers/activitypub/inboxes_controller_spec.rb' - - 'spec/controllers/admin/domain_blocks_controller_spec.rb' - - 'spec/controllers/admin/reports/actions_controller_spec.rb' - - 'spec/controllers/admin/statuses_controller_spec.rb' - - 'spec/controllers/api/v1/accounts/relationships_controller_spec.rb' - - 'spec/controllers/api/v1/accounts_controller_spec.rb' - - 'spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb' - - 'spec/controllers/api/v1/emails/confirmations_controller_spec.rb' - - 'spec/controllers/api/v1/instances/activity_controller_spec.rb' - - 'spec/controllers/api/v1/instances/peers_controller_spec.rb' - - 'spec/controllers/api/v1/media_controller_spec.rb' - - 'spec/controllers/api/v2/filters_controller_spec.rb' - - 'spec/controllers/application_controller_spec.rb' - - 'spec/controllers/auth/registrations_controller_spec.rb' - - 'spec/controllers/auth/sessions_controller_spec.rb' - - 'spec/controllers/concerns/cache_concern_spec.rb' - - 'spec/controllers/concerns/challengable_concern_spec.rb' - - 'spec/controllers/concerns/localized_spec.rb' - - 'spec/controllers/concerns/rate_limit_headers_spec.rb' - - 'spec/controllers/instance_actors_controller_spec.rb' - - 'spec/controllers/settings/applications_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb' - - 'spec/controllers/statuses_controller_spec.rb' - - 'spec/helpers/admin/account_moderation_notes_helper_spec.rb' - - 'spec/helpers/jsonld_helper_spec.rb' - - 'spec/helpers/routing_helper_spec.rb' - - 'spec/lib/activitypub/activity/accept_spec.rb' - - 'spec/lib/activitypub/activity/announce_spec.rb' - - 'spec/lib/activitypub/activity/create_spec.rb' - - 'spec/lib/activitypub/activity/follow_spec.rb' - - 'spec/lib/activitypub/activity/reject_spec.rb' - - 'spec/lib/emoji_formatter_spec.rb' - - 'spec/lib/entity_cache_spec.rb' - - 'spec/lib/feed_manager_spec.rb' - - 'spec/lib/html_aware_formatter_spec.rb' - - 'spec/lib/link_details_extractor_spec.rb' - - 'spec/lib/ostatus/tag_manager_spec.rb' - - 'spec/lib/plain_text_formatter_spec.rb' - - 'spec/lib/scope_transformer_spec.rb' - - 'spec/lib/status_cache_hydrator_spec.rb' - - 'spec/lib/status_reach_finder_spec.rb' - - 'spec/lib/text_formatter_spec.rb' - - 'spec/models/account/field_spec.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/admin/account_action_spec.rb' - - 'spec/models/concerns/account_interactions_spec.rb' - - 'spec/models/concerns/remotable_spec.rb' - - 'spec/models/custom_emoji_filter_spec.rb' - - 'spec/models/custom_emoji_spec.rb' - - 'spec/models/email_domain_block_spec.rb' - - 'spec/models/media_attachment_spec.rb' - - 'spec/models/notification_spec.rb' - - 'spec/models/remote_follow_spec.rb' - - 'spec/models/report_spec.rb' - - 'spec/models/session_activation_spec.rb' - - 'spec/models/setting_spec.rb' - - 'spec/models/status_spec.rb' - - 'spec/models/web/push_subscription_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/presenters/account_relationships_presenter_spec.rb' - - 'spec/presenters/status_relationships_presenter_spec.rb' - - 'spec/services/account_search_service_spec.rb' - - 'spec/services/account_statuses_cleanup_service_spec.rb' - - 'spec/services/activitypub/fetch_remote_status_service_spec.rb' - - 'spec/services/activitypub/process_account_service_spec.rb' - - 'spec/services/activitypub/process_status_update_service_spec.rb' - - 'spec/services/fetch_link_card_service_spec.rb' - - 'spec/services/fetch_oembed_service_spec.rb' - - 'spec/services/fetch_remote_status_service_spec.rb' - - 'spec/services/follow_service_spec.rb' - - 'spec/services/import_service_spec.rb' - - 'spec/services/notify_service_spec.rb' - - 'spec/services/process_mentions_service_spec.rb' - - 'spec/services/reblog_service_spec.rb' - - 'spec/services/report_service_spec.rb' - - 'spec/services/resolve_account_service_spec.rb' - - 'spec/services/resolve_url_service_spec.rb' - - 'spec/services/search_service_spec.rb' - - 'spec/services/unallow_domain_service_spec.rb' - - 'spec/services/verify_link_service_spec.rb' - - 'spec/validators/disallowed_hashtags_validator_spec.rb' - - 'spec/validators/email_mx_validator_spec.rb' - - 'spec/validators/follow_limit_validator_spec.rb' - - 'spec/validators/poll_validator_spec.rb' - - 'spec/validators/status_pin_validator_spec.rb' - - 'spec/validators/unreserved_username_validator_spec.rb' - - 'spec/validators/url_validator_spec.rb' - - 'spec/workers/move_worker_spec.rb' - - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb' - -# Offense count: 339 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: SkipBlocks, EnforcedStyle. -# SupportedStyles: described_class, explicit -RSpec/DescribedClass: - Exclude: - - 'spec/controllers/concerns/cache_concern_spec.rb' - - 'spec/controllers/concerns/challengable_concern_spec.rb' - - 'spec/lib/entity_cache_spec.rb' - - 'spec/lib/extractor_spec.rb' - - 'spec/lib/feed_manager_spec.rb' - - 'spec/lib/hash_object_spec.rb' - - 'spec/lib/ostatus/tag_manager_spec.rb' - - 'spec/lib/request_spec.rb' - - 'spec/lib/tag_manager_spec.rb' - - 'spec/lib/webfinger_resource_spec.rb' - - 'spec/mailers/notification_mailer_spec.rb' - - 'spec/mailers/user_mailer_spec.rb' - - 'spec/models/account_conversation_spec.rb' - - 'spec/models/account_domain_block_spec.rb' - - 'spec/models/account_migration_spec.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/block_spec.rb' - - 'spec/models/domain_block_spec.rb' - - 'spec/models/email_domain_block_spec.rb' - - 'spec/models/export_spec.rb' - - 'spec/models/favourite_spec.rb' - - 'spec/models/follow_spec.rb' - - 'spec/models/identity_spec.rb' - - 'spec/models/import_spec.rb' - - 'spec/models/media_attachment_spec.rb' - - 'spec/models/notification_spec.rb' - - 'spec/models/relationship_filter_spec.rb' - - 'spec/models/report_filter_spec.rb' - - 'spec/models/session_activation_spec.rb' - - 'spec/models/setting_spec.rb' - - 'spec/models/site_upload_spec.rb' - - 'spec/models/status_pin_spec.rb' - - 'spec/models/status_spec.rb' - - 'spec/models/user_spec.rb' - - 'spec/policies/account_moderation_note_policy_spec.rb' - - 'spec/presenters/account_relationships_presenter_spec.rb' - - 'spec/presenters/instance_presenter_spec.rb' - - 'spec/presenters/status_relationships_presenter_spec.rb' - - 'spec/serializers/activitypub/note_spec.rb' - - 'spec/serializers/activitypub/update_poll_spec.rb' - - 'spec/serializers/rest/account_serializer_spec.rb' - - 'spec/services/activitypub/fetch_remote_account_service_spec.rb' - - 'spec/services/activitypub/fetch_remote_actor_service_spec.rb' - - 'spec/services/activitypub/fetch_remote_key_service_spec.rb' - - 'spec/services/after_block_domain_from_account_service_spec.rb' - - 'spec/services/authorize_follow_service_spec.rb' - - 'spec/services/batched_remove_status_service_spec.rb' - - 'spec/services/block_domain_service_spec.rb' - - 'spec/services/block_service_spec.rb' - - 'spec/services/bootstrap_timeline_service_spec.rb' - - 'spec/services/clear_domain_media_service_spec.rb' - - 'spec/services/favourite_service_spec.rb' - - 'spec/services/follow_service_spec.rb' - - 'spec/services/import_service_spec.rb' - - 'spec/services/post_status_service_spec.rb' - - 'spec/services/precompute_feed_service_spec.rb' - - 'spec/services/process_mentions_service_spec.rb' - - 'spec/services/purge_domain_service_spec.rb' - - 'spec/services/reblog_service_spec.rb' - - 'spec/services/reject_follow_service_spec.rb' - - 'spec/services/remove_from_follwers_service_spec.rb' - - 'spec/services/remove_status_service_spec.rb' - - 'spec/services/unallow_domain_service_spec.rb' - - 'spec/services/unblock_service_spec.rb' - - 'spec/services/unfollow_service_spec.rb' - - 'spec/services/unmute_service_spec.rb' - - 'spec/services/update_account_service_spec.rb' - - 'spec/validators/note_length_validator_spec.rb' - -# Offense count: 32 -# This cop supports unsafe autocorrection (--autocorrect-all). -RSpec/EmptyExampleGroup: - Exclude: - - 'spec/helpers/admin/action_log_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/one_time_key_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' - -# Offense count: 178 # Configuration parameters: CountAsOne. RSpec/ExampleLength: Max: 22 -# Offense count: 21 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: method_call, block -RSpec/ExpectChange: - Exclude: - - 'spec/controllers/admin/account_moderation_notes_controller_spec.rb' - - 'spec/controllers/admin/custom_emojis_controller_spec.rb' - - 'spec/controllers/admin/invites_controller_spec.rb' - - 'spec/controllers/admin/report_notes_controller_spec.rb' - - 'spec/controllers/concerns/accountable_concern_spec.rb' - - 'spec/controllers/invites_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb' - - 'spec/models/admin/account_action_spec.rb' - - 'spec/services/suspend_account_service_spec.rb' - - 'spec/services/unsuspend_account_service_spec.rb' - - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb' - -# Offense count: 5 -RSpec/ExpectInHook: - Exclude: - - 'spec/controllers/api/v1/media_controller_spec.rb' - - 'spec/controllers/settings/applications_controller_spec.rb' - - 'spec/lib/status_filter_spec.rb' - -# Offense count: 61 -# Configuration parameters: Include, CustomTransform, IgnoreMethods, SpecSuffixOnly. -# Include: **/*_spec*rb*, **/spec/**/* -RSpec/FilePath: - Exclude: - - 'spec/config/initializers/rack_attack_spec.rb' - - 'spec/controllers/activitypub/collections_controller_spec.rb' - - 'spec/controllers/activitypub/followers_synchronizations_controller_spec.rb' - - 'spec/controllers/activitypub/inboxes_controller_spec.rb' - - 'spec/controllers/activitypub/outboxes_controller_spec.rb' - - 'spec/controllers/activitypub/replies_controller_spec.rb' - - 'spec/controllers/admin/change_email_controller_spec.rb' - - 'spec/controllers/admin/users/roles_controller.rb' - - 'spec/controllers/api/oembed_controller_spec.rb' - - 'spec/controllers/concerns/account_controller_concern_spec.rb' - - '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' - - 'spec/controllers/well_known/nodeinfo_controller_spec.rb' - - 'spec/helpers/admin/action_log_helper_spec.rb' - - 'spec/helpers/jsonld_helper_spec.rb' - - 'spec/lib/activitypub/activity/accept_spec.rb' - - 'spec/lib/activitypub/activity/add_spec.rb' - - 'spec/lib/activitypub/activity/announce_spec.rb' - - 'spec/lib/activitypub/activity/block_spec.rb' - - 'spec/lib/activitypub/activity/create_spec.rb' - - 'spec/lib/activitypub/activity/delete_spec.rb' - - 'spec/lib/activitypub/activity/flag_spec.rb' - - 'spec/lib/activitypub/activity/follow_spec.rb' - - 'spec/lib/activitypub/activity/like_spec.rb' - - 'spec/lib/activitypub/activity/move_spec.rb' - - 'spec/lib/activitypub/activity/reject_spec.rb' - - 'spec/lib/activitypub/activity/remove_spec.rb' - - 'spec/lib/activitypub/activity/undo_spec.rb' - - 'spec/lib/activitypub/activity/update_spec.rb' - - 'spec/lib/activitypub/adapter_spec.rb' - - 'spec/lib/activitypub/dereferencer_spec.rb' - - 'spec/lib/activitypub/linked_data_signature_spec.rb' - - 'spec/lib/activitypub/tag_manager_spec.rb' - - 'spec/lib/ostatus/tag_manager_spec.rb' - - 'spec/lib/sanitize_config_spec.rb' - - 'spec/serializers/activitypub/note_spec.rb' - - 'spec/serializers/activitypub/update_poll_spec.rb' - - 'spec/services/activitypub/fetch_featured_collection_service_spec.rb' - - 'spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb' - - 'spec/services/activitypub/fetch_remote_account_service_spec.rb' - - 'spec/services/activitypub/fetch_remote_actor_service_spec.rb' - - 'spec/services/activitypub/fetch_remote_key_service_spec.rb' - - 'spec/services/activitypub/fetch_remote_status_service_spec.rb' - - 'spec/services/activitypub/fetch_replies_service_spec.rb' - - 'spec/services/activitypub/process_account_service_spec.rb' - - 'spec/services/activitypub/process_collection_service_spec.rb' - - 'spec/services/activitypub/process_status_update_service_spec.rb' - - 'spec/services/activitypub/synchronize_followers_service_spec.rb' - - 'spec/services/fetch_oembed_service_spec.rb' - - 'spec/services/remove_from_follwers_service_spec.rb' - - 'spec/workers/activitypub/delivery_worker_spec.rb' - - 'spec/workers/activitypub/distribute_poll_update_worker_spec.rb' - - 'spec/workers/activitypub/distribution_worker_spec.rb' - - 'spec/workers/activitypub/fetch_replies_worker_spec.rb' - - 'spec/workers/activitypub/move_distribution_worker_spec.rb' - - 'spec/workers/activitypub/processing_worker_spec.rb' - - 'spec/workers/activitypub/status_update_distribution_worker_spec.rb' - - 'spec/workers/activitypub/update_distribution_worker_spec.rb' - -# Offense count: 16 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: implicit, each, example @@ -733,17 +104,14 @@ RSpec/HookArgument: - 'spec/helpers/instance_helper_spec.rb' - 'spec/models/user_spec.rb' - 'spec/rails_helper.rb' - - 'spec/serializers/activitypub/note_spec.rb' - - 'spec/serializers/activitypub/update_poll_spec.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' -# Offense count: 101 # Configuration parameters: AssignmentOnly. RSpec/InstanceVariable: Exclude: - 'spec/controllers/api/v1/streaming_controller_spec.rb' - - 'spec/controllers/application_controller_spec.rb' - 'spec/controllers/auth/confirmations_controller_spec.rb' - 'spec/controllers/auth/passwords_controller_spec.rb' - 'spec/controllers/auth/sessions_controller_spec.rb' @@ -753,28 +121,13 @@ RSpec/InstanceVariable: - 'spec/controllers/statuses_cleanup_controller_spec.rb' - 'spec/models/concerns/account_finder_concern_spec.rb' - 'spec/models/concerns/account_interactions_spec.rb' - - 'spec/models/concerns/remotable_spec.rb' - 'spec/models/public_feed_spec.rb' - - 'spec/serializers/activitypub/note_spec.rb' - - 'spec/serializers/activitypub/update_poll_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' -# Offense count: 15 -RSpec/LeakyConstantDeclaration: - Exclude: - - 'spec/controllers/api/base_controller_spec.rb' - - 'spec/controllers/application_controller_spec.rb' - - 'spec/controllers/concerns/accountable_concern_spec.rb' - - 'spec/controllers/concerns/signature_verification_spec.rb' - - 'spec/lib/activitypub/adapter_spec.rb' - - 'spec/lib/connection_pool/shared_connection_pool_spec.rb' - - 'spec/lib/connection_pool/shared_timed_stack_spec.rb' - - 'spec/lib/settings/extend_spec.rb' - - 'spec/models/concerns/remotable_spec.rb' - -# Offense count: 108 RSpec/LetSetup: Exclude: - 'spec/controllers/admin/accounts_controller_spec.rb' @@ -783,16 +136,10 @@ RSpec/LetSetup: - '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/admin/domain_allows_controller_spec.rb' - - 'spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb' - 'spec/controllers/api/v1/filters_controller_spec.rb' - - 'spec/controllers/api/v1/followed_tags_controller_spec.rb' - - 'spec/controllers/api/v1/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' @@ -800,8 +147,9 @@ RSpec/LetSetup: - 'spec/controllers/following_accounts_controller_spec.rb' - 'spec/controllers/oauth/authorized_applications_controller_spec.rb' - 'spec/controllers/oauth/tokens_controller_spec.rb' - - 'spec/controllers/tags_controller_spec.rb' + - 'spec/controllers/settings/imports_controller_spec.rb' - 'spec/lib/activitypub/activity/delete_spec.rb' + - 'spec/lib/vacuum/applications_vacuum_spec.rb' - 'spec/lib/vacuum/preview_cards_vacuum_spec.rb' - 'spec/models/account_spec.rb' - 'spec/models/account_statuses_cleanup_policy_spec.rb' @@ -815,6 +163,7 @@ RSpec/LetSetup: - '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' @@ -824,28 +173,20 @@ RSpec/LetSetup: - 'spec/services/suspend_account_service_spec.rb' - 'spec/services/unallow_domain_service_spec.rb' - 'spec/services/unsuspend_account_service_spec.rb' - - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb' - 'spec/workers/scheduler/user_cleanup_scheduler_spec.rb' -# Offense count: 7 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' -# Offense count: 47 # 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' @@ -858,801 +199,29 @@ RSpec/MessageSpies: - 'spec/spec_helper.rb' - 'spec/validators/status_length_validator_spec.rb' -# Offense count: 35 -RSpec/MissingExampleGroupArgument: - Exclude: - - 'spec/controllers/accounts_controller_spec.rb' - - 'spec/controllers/activitypub/collections_controller_spec.rb' - - 'spec/controllers/admin/statuses_controller_spec.rb' - - 'spec/controllers/admin/users/roles_controller.rb' - - 'spec/controllers/api/v1/accounts_controller_spec.rb' - - 'spec/controllers/api/v1/admin/account_actions_controller_spec.rb' - - 'spec/controllers/api/v1/admin/domain_allows_controller_spec.rb' - - 'spec/controllers/api/v1/statuses_controller_spec.rb' - - 'spec/controllers/application_controller_spec.rb' - - 'spec/controllers/auth/registrations_controller_spec.rb' - - 'spec/features/log_in_spec.rb' - - 'spec/lib/activitypub/activity/undo_spec.rb' - - 'spec/lib/status_reach_finder_spec.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/email_domain_block_spec.rb' - - 'spec/models/trends/statuses_spec.rb' - - 'spec/models/trends/tags_spec.rb' - - 'spec/models/user_role_spec.rb' - - 'spec/models/user_spec.rb' - - 'spec/services/fetch_link_card_service_spec.rb' - - 'spec/services/notify_service_spec.rb' - - 'spec/services/process_mentions_service_spec.rb' - -# Offense count: 599 RSpec/MultipleExpectations: - Max: 19 + Max: 8 -# Offense count: 442 # Configuration parameters: AllowSubject. RSpec/MultipleMemoizedHelpers: Max: 21 -# Offense count: 7 -# This cop supports safe autocorrection (--autocorrect). -RSpec/MultipleSubjects: - Exclude: - - 'spec/controllers/activitypub/collections_controller_spec.rb' - - 'spec/controllers/activitypub/followers_synchronizations_controller_spec.rb' - - 'spec/controllers/activitypub/outboxes_controller_spec.rb' - - 'spec/controllers/api/web/embeds_controller_spec.rb' - - 'spec/controllers/emojis_controller_spec.rb' - - 'spec/controllers/follower_accounts_controller_spec.rb' - - 'spec/controllers/following_accounts_controller_spec.rb' - -# Offense count: 1407 -# Configuration parameters: EnforcedStyle, IgnoreSharedExamples. -# SupportedStyles: always, named_only -RSpec/NamedSubject: - Exclude: - - 'spec/controllers/admin/account_moderation_notes_controller_spec.rb' - - 'spec/controllers/admin/accounts_controller_spec.rb' - - 'spec/controllers/admin/confirmations_controller_spec.rb' - - 'spec/controllers/admin/custom_emojis_controller_spec.rb' - - 'spec/controllers/admin/domain_blocks_controller_spec.rb' - - 'spec/controllers/admin/instances_controller_spec.rb' - - 'spec/controllers/admin/invites_controller_spec.rb' - - 'spec/controllers/admin/report_notes_controller_spec.rb' - - 'spec/controllers/api/v1/accounts/notes_controller_spec.rb' - - 'spec/controllers/api/v1/accounts/pins_controller_spec.rb' - - 'spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb' - - 'spec/controllers/auth/passwords_controller_spec.rb' - - 'spec/controllers/auth/registrations_controller_spec.rb' - - 'spec/controllers/home_controller_spec.rb' - - 'spec/controllers/invites_controller_spec.rb' - - 'spec/controllers/oauth/authorizations_controller_spec.rb' - - 'spec/controllers/oauth/authorized_applications_controller_spec.rb' - - 'spec/controllers/relationships_controller_spec.rb' - - 'spec/controllers/settings/featured_tags_controller_spec.rb' - - 'spec/controllers/settings/migrations_controller_spec.rb' - - 'spec/controllers/settings/sessions_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb' - - 'spec/controllers/well_known/webfinger_controller_spec.rb' - - 'spec/features/log_in_spec.rb' - - 'spec/features/profile_spec.rb' - - 'spec/lib/activitypub/activity/accept_spec.rb' - - 'spec/lib/activitypub/activity/add_spec.rb' - - 'spec/lib/activitypub/activity/announce_spec.rb' - - 'spec/lib/activitypub/activity/block_spec.rb' - - 'spec/lib/activitypub/activity/create_spec.rb' - - 'spec/lib/activitypub/activity/delete_spec.rb' - - 'spec/lib/activitypub/activity/flag_spec.rb' - - 'spec/lib/activitypub/activity/follow_spec.rb' - - 'spec/lib/activitypub/activity/like_spec.rb' - - 'spec/lib/activitypub/activity/move_spec.rb' - - 'spec/lib/activitypub/activity/reject_spec.rb' - - 'spec/lib/activitypub/activity/remove_spec.rb' - - 'spec/lib/activitypub/activity/undo_spec.rb' - - 'spec/lib/activitypub/activity/update_spec.rb' - - 'spec/lib/activitypub/adapter_spec.rb' - - 'spec/lib/activitypub/dereferencer_spec.rb' - - 'spec/lib/activitypub/linked_data_signature_spec.rb' - - 'spec/lib/activitypub/tag_manager_spec.rb' - - 'spec/lib/connection_pool/shared_connection_pool_spec.rb' - - 'spec/lib/connection_pool/shared_timed_stack_spec.rb' - - 'spec/lib/delivery_failure_tracker_spec.rb' - - 'spec/lib/emoji_formatter_spec.rb' - - 'spec/lib/entity_cache_spec.rb' - - 'spec/lib/fast_ip_map_spec.rb' - - 'spec/lib/feed_manager_spec.rb' - - 'spec/lib/hashtag_normalizer_spec.rb' - - 'spec/lib/html_aware_formatter_spec.rb' - - 'spec/lib/link_details_extractor_spec.rb' - - 'spec/lib/ostatus/tag_manager_spec.rb' - - 'spec/lib/plain_text_formatter_spec.rb' - - 'spec/lib/request_pool_spec.rb' - - 'spec/lib/request_spec.rb' - - 'spec/lib/sanitize_config_spec.rb' - - 'spec/lib/status_finder_spec.rb' - - 'spec/lib/status_reach_finder_spec.rb' - - 'spec/lib/suspicious_sign_in_detector_spec.rb' - - 'spec/lib/text_formatter_spec.rb' - - 'spec/lib/vacuum/access_tokens_vacuum_spec.rb' - - 'spec/lib/vacuum/backups_vacuum_spec.rb' - - 'spec/lib/vacuum/feeds_vacuum_spec.rb' - - 'spec/lib/vacuum/media_attachments_vacuum_spec.rb' - - 'spec/lib/vacuum/preview_cards_vacuum_spec.rb' - - 'spec/lib/vacuum/statuses_vacuum_spec.rb' - - 'spec/lib/vacuum/system_keys_vacuum_spec.rb' - - 'spec/models/account/field_spec.rb' - - 'spec/models/account_migration_spec.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/account_statuses_cleanup_policy_spec.rb' - - 'spec/models/account_statuses_filter_spec.rb' - - 'spec/models/admin/account_action_spec.rb' - - 'spec/models/canonical_email_block_spec.rb' - - 'spec/models/concerns/account_interactions_spec.rb' - - 'spec/models/custom_emoji_filter_spec.rb' - - 'spec/models/custom_emoji_spec.rb' - - 'spec/models/follow_spec.rb' - - 'spec/models/home_feed_spec.rb' - - 'spec/models/media_attachment_spec.rb' - - 'spec/models/notification_spec.rb' - - 'spec/models/public_feed_spec.rb' - - 'spec/models/relationship_filter_spec.rb' - - 'spec/models/remote_follow_spec.rb' - - 'spec/models/report_spec.rb' - - 'spec/models/session_activation_spec.rb' - - 'spec/models/setting_spec.rb' - - 'spec/models/status_spec.rb' - - 'spec/models/tag_spec.rb' - - 'spec/models/trends/statuses_spec.rb' - - 'spec/models/trends/tags_spec.rb' - - 'spec/models/user_role_spec.rb' - - 'spec/models/user_spec.rb' - - 'spec/models/web/push_subscription_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/status_policy_spec.rb' - - 'spec/policies/tag_policy_spec.rb' - - 'spec/policies/user_policy_spec.rb' - - 'spec/presenters/familiar_followers_presenter_spec.rb' - - 'spec/serializers/activitypub/note_spec.rb' - - 'spec/serializers/activitypub/update_poll_spec.rb' - - 'spec/serializers/rest/account_serializer_spec.rb' - - 'spec/services/account_search_service_spec.rb' - - 'spec/services/account_statuses_cleanup_service_spec.rb' - - 'spec/services/activitypub/fetch_remote_account_service_spec.rb' - - 'spec/services/activitypub/fetch_remote_actor_service_spec.rb' - - 'spec/services/activitypub/fetch_remote_status_service_spec.rb' - - 'spec/services/activitypub/fetch_replies_service_spec.rb' - - 'spec/services/activitypub/process_account_service_spec.rb' - - 'spec/services/activitypub/process_collection_service_spec.rb' - - 'spec/services/activitypub/process_status_update_service_spec.rb' - - 'spec/services/after_block_domain_from_account_service_spec.rb' - - 'spec/services/after_block_service_spec.rb' - - 'spec/services/app_sign_up_service_spec.rb' - - 'spec/services/authorize_follow_service_spec.rb' - - 'spec/services/batched_remove_status_service_spec.rb' - - 'spec/services/block_domain_service_spec.rb' - - 'spec/services/block_service_spec.rb' - - 'spec/services/bootstrap_timeline_service_spec.rb' - - 'spec/services/clear_domain_media_service_spec.rb' - - 'spec/services/delete_account_service_spec.rb' - - 'spec/services/fan_out_on_write_service_spec.rb' - - 'spec/services/favourite_service_spec.rb' - - 'spec/services/fetch_link_card_service_spec.rb' - - 'spec/services/fetch_oembed_service_spec.rb' - - 'spec/services/fetch_remote_status_service_spec.rb' - - 'spec/services/fetch_resource_service_spec.rb' - - 'spec/services/follow_service_spec.rb' - - 'spec/services/import_service_spec.rb' - - 'spec/services/mute_service_spec.rb' - - 'spec/services/notify_service_spec.rb' - - 'spec/services/post_status_service_spec.rb' - - 'spec/services/precompute_feed_service_spec.rb' - - 'spec/services/process_mentions_service_spec.rb' - - 'spec/services/purge_domain_service_spec.rb' - - 'spec/services/reblog_service_spec.rb' - - 'spec/services/reject_follow_service_spec.rb' - - 'spec/services/remove_from_follwers_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/resolve_url_service_spec.rb' - - 'spec/services/search_service_spec.rb' - - 'spec/services/suspend_account_service_spec.rb' - - 'spec/services/unallow_domain_service_spec.rb' - - 'spec/services/unblock_domain_service_spec.rb' - - 'spec/services/unblock_service_spec.rb' - - 'spec/services/unfollow_service_spec.rb' - - 'spec/services/unsuspend_account_service_spec.rb' - - 'spec/services/update_account_service_spec.rb' - - 'spec/services/update_status_service_spec.rb' - - 'spec/services/verify_link_service_spec.rb' - - 'spec/validators/blacklisted_email_validator_spec.rb' - - 'spec/validators/email_mx_validator_spec.rb' - - 'spec/validators/note_length_validator_spec.rb' - - 'spec/validators/reaction_validator_spec.rb' - - 'spec/validators/status_length_validator_spec.rb' - - 'spec/validators/status_pin_validator_spec.rb' - - 'spec/validators/unique_username_validator_spec.rb' - - 'spec/workers/activitypub/delivery_worker_spec.rb' - - 'spec/workers/activitypub/distribute_poll_update_worker_spec.rb' - - 'spec/workers/activitypub/distribution_worker_spec.rb' - - 'spec/workers/activitypub/fetch_replies_worker_spec.rb' - - 'spec/workers/activitypub/move_distribution_worker_spec.rb' - - 'spec/workers/activitypub/processing_worker_spec.rb' - - 'spec/workers/activitypub/status_update_distribution_worker_spec.rb' - - 'spec/workers/activitypub/update_distribution_worker_spec.rb' - - 'spec/workers/admin/domain_purge_worker_spec.rb' - - 'spec/workers/domain_block_worker_spec.rb' - - 'spec/workers/domain_clear_media_worker_spec.rb' - - 'spec/workers/feed_insert_worker_spec.rb' - - 'spec/workers/move_worker_spec.rb' - - 'spec/workers/publish_scheduled_announcement_worker_spec.rb' - - 'spec/workers/publish_scheduled_status_worker_spec.rb' - - 'spec/workers/refollow_worker_spec.rb' - - 'spec/workers/regeneration_worker_spec.rb' - - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb' - - 'spec/workers/scheduler/user_cleanup_scheduler_spec.rb' - - 'spec/workers/unfollow_follow_worker_spec.rb' - - 'spec/workers/web/push_notification_worker_spec.rb' - -# Offense count: 552 # Configuration parameters: AllowedGroups. RSpec/NestedGroups: Max: 6 -# Offense count: 2 -# Configuration parameters: AllowedPatterns. -# AllowedPatterns: ^expect_, ^assert_ -RSpec/NoExpectationExample: - Exclude: - - 'spec/controllers/auth/registrations_controller_spec.rb' - - 'spec/services/precompute_feed_service_spec.rb' - -# Offense count: 3 -RSpec/PendingWithoutReason: - Exclude: - - 'spec/models/account_spec.rb' - - 'spec/support/examples/lib/settings/scoped_settings.rb' - -# Offense count: 9 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: Strict, EnforcedStyle, AllowedExplicitMatchers. -# SupportedStyles: inflected, explicit -RSpec/PredicateMatcher: - Exclude: - - 'spec/controllers/api/v1/accounts/notes_controller_spec.rb' - - 'spec/models/user_spec.rb' - - 'spec/services/post_status_service_spec.rb' - -# Offense count: 180 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: Inferences. -RSpec/Rails/InferredSpecType: - Exclude: - - 'spec/controllers/about_controller_spec.rb' - - 'spec/controllers/accounts_controller_spec.rb' - - 'spec/controllers/activitypub/collections_controller_spec.rb' - - 'spec/controllers/activitypub/followers_synchronizations_controller_spec.rb' - - 'spec/controllers/activitypub/inboxes_controller_spec.rb' - - 'spec/controllers/activitypub/outboxes_controller_spec.rb' - - 'spec/controllers/activitypub/replies_controller_spec.rb' - - 'spec/controllers/admin/account_moderation_notes_controller_spec.rb' - - 'spec/controllers/admin/accounts_controller_spec.rb' - - 'spec/controllers/admin/action_logs_controller_spec.rb' - - 'spec/controllers/admin/base_controller_spec.rb' - - 'spec/controllers/admin/change_email_controller_spec.rb' - - 'spec/controllers/admin/confirmations_controller_spec.rb' - - 'spec/controllers/admin/dashboard_controller_spec.rb' - - 'spec/controllers/admin/disputes/appeals_controller_spec.rb' - - 'spec/controllers/admin/domain_allows_controller_spec.rb' - - 'spec/controllers/admin/domain_blocks_controller_spec.rb' - - 'spec/controllers/admin/email_domain_blocks_controller_spec.rb' - - 'spec/controllers/admin/export_domain_allows_controller_spec.rb' - - 'spec/controllers/admin/export_domain_blocks_controller_spec.rb' - - 'spec/controllers/admin/instances_controller_spec.rb' - - 'spec/controllers/admin/settings/branding_controller_spec.rb' - - 'spec/controllers/admin/tags_controller_spec.rb' - - 'spec/controllers/api/oembed_controller_spec.rb' - - 'spec/controllers/api/v1/accounts/pins_controller_spec.rb' - - 'spec/controllers/api/v1/accounts/search_controller_spec.rb' - - 'spec/controllers/api/v1/accounts_controller_spec.rb' - - 'spec/controllers/api/v1/admin/account_actions_controller_spec.rb' - - 'spec/controllers/api/v1/admin/accounts_controller_spec.rb' - - 'spec/controllers/api/v1/admin/domain_allows_controller_spec.rb' - - 'spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb' - - 'spec/controllers/api/v1/admin/reports_controller_spec.rb' - - 'spec/controllers/api/v1/announcements/reactions_controller_spec.rb' - - 'spec/controllers/api/v1/announcements_controller_spec.rb' - - 'spec/controllers/api/v1/apps_controller_spec.rb' - - 'spec/controllers/api/v1/blocks_controller_spec.rb' - - 'spec/controllers/api/v1/bookmarks_controller_spec.rb' - - 'spec/controllers/api/v1/conversations_controller_spec.rb' - - 'spec/controllers/api/v1/custom_emojis_controller_spec.rb' - - 'spec/controllers/api/v1/domain_blocks_controller_spec.rb' - - 'spec/controllers/api/v1/emails/confirmations_controller_spec.rb' - - 'spec/controllers/api/v1/endorsements_controller_spec.rb' - - 'spec/controllers/api/v1/favourites_controller_spec.rb' - - 'spec/controllers/api/v1/filters_controller_spec.rb' - - 'spec/controllers/api/v1/follow_requests_controller_spec.rb' - - 'spec/controllers/api/v1/followed_tags_controller_spec.rb' - - 'spec/controllers/api/v1/instances/activity_controller_spec.rb' - - 'spec/controllers/api/v1/instances/peers_controller_spec.rb' - - 'spec/controllers/api/v1/instances_controller_spec.rb' - - 'spec/controllers/api/v1/lists_controller_spec.rb' - - 'spec/controllers/api/v1/markers_controller_spec.rb' - - 'spec/controllers/api/v1/media_controller_spec.rb' - - 'spec/controllers/api/v1/mutes_controller_spec.rb' - - 'spec/controllers/api/v1/notifications_controller_spec.rb' - - 'spec/controllers/api/v1/polls/votes_controller_spec.rb' - - 'spec/controllers/api/v1/polls_controller_spec.rb' - - 'spec/controllers/api/v1/reports_controller_spec.rb' - - 'spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb' - - 'spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb' - - 'spec/controllers/api/v1/statuses_controller_spec.rb' - - 'spec/controllers/api/v1/suggestions_controller_spec.rb' - - 'spec/controllers/api/v1/tags_controller_spec.rb' - - 'spec/controllers/api/v1/trends/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/api/v2/search_controller_spec.rb' - - 'spec/controllers/application_controller_spec.rb' - - 'spec/controllers/auth/challenges_controller_spec.rb' - - 'spec/controllers/auth/confirmations_controller_spec.rb' - - 'spec/controllers/auth/passwords_controller_spec.rb' - - 'spec/controllers/auth/registrations_controller_spec.rb' - - 'spec/controllers/auth/sessions_controller_spec.rb' - - 'spec/controllers/concerns/account_controller_concern_spec.rb' - - 'spec/controllers/concerns/cache_concern_spec.rb' - - 'spec/controllers/concerns/challengable_concern_spec.rb' - - 'spec/controllers/concerns/export_controller_concern_spec.rb' - - 'spec/controllers/concerns/localized_spec.rb' - - 'spec/controllers/concerns/signature_verification_spec.rb' - - 'spec/controllers/concerns/user_tracking_concern_spec.rb' - - 'spec/controllers/disputes/appeals_controller_spec.rb' - - 'spec/controllers/disputes/strikes_controller_spec.rb' - - 'spec/controllers/home_controller_spec.rb' - - 'spec/controllers/instance_actors_controller_spec.rb' - - 'spec/controllers/intents_controller_spec.rb' - - 'spec/controllers/oauth/authorizations_controller_spec.rb' - - 'spec/controllers/oauth/tokens_controller_spec.rb' - - 'spec/controllers/settings/imports_controller_spec.rb' - - 'spec/controllers/settings/profiles_controller_spec.rb' - - 'spec/controllers/statuses_cleanup_controller_spec.rb' - - 'spec/controllers/tags_controller_spec.rb' - - 'spec/controllers/well_known/host_meta_controller_spec.rb' - - 'spec/controllers/well_known/nodeinfo_controller_spec.rb' - - 'spec/controllers/well_known/webfinger_controller_spec.rb' - - 'spec/helpers/accounts_helper_spec.rb' - - 'spec/helpers/admin/account_moderation_notes_helper_spec.rb' - - 'spec/helpers/admin/action_log_helper_spec.rb' - - 'spec/helpers/flashes_helper_spec.rb' - - 'spec/helpers/formatting_helper_spec.rb' - - 'spec/helpers/home_helper_spec.rb' - - 'spec/helpers/routing_helper_spec.rb' - - 'spec/helpers/statuses_helper_spec.rb' - - 'spec/mailers/admin_mailer_spec.rb' - - 'spec/mailers/notification_mailer_spec.rb' - - 'spec/mailers/user_mailer_spec.rb' - - 'spec/models/account/field_spec.rb' - - 'spec/models/account_alias_spec.rb' - - 'spec/models/account_conversation_spec.rb' - - 'spec/models/account_deletion_request_spec.rb' - - 'spec/models/account_domain_block_spec.rb' - - 'spec/models/account_migration_spec.rb' - - 'spec/models/account_moderation_note_spec.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/account_statuses_cleanup_policy_spec.rb' - - 'spec/models/admin/account_action_spec.rb' - - 'spec/models/admin/action_log_spec.rb' - - 'spec/models/announcement_mute_spec.rb' - - 'spec/models/announcement_reaction_spec.rb' - - 'spec/models/announcement_spec.rb' - - 'spec/models/appeal_spec.rb' - - 'spec/models/backup_spec.rb' - - 'spec/models/block_spec.rb' - - 'spec/models/canonical_email_block_spec.rb' - - 'spec/models/conversation_mute_spec.rb' - - 'spec/models/conversation_spec.rb' - - 'spec/models/custom_emoji_category_spec.rb' - - 'spec/models/custom_emoji_spec.rb' - - 'spec/models/custom_filter_keyword_spec.rb' - - 'spec/models/custom_filter_spec.rb' - - 'spec/models/device_spec.rb' - - 'spec/models/domain_allow_spec.rb' - - 'spec/models/domain_block_spec.rb' - - 'spec/models/email_domain_block_spec.rb' - - 'spec/models/encrypted_message_spec.rb' - - 'spec/models/favourite_spec.rb' - - 'spec/models/featured_tag_spec.rb' - - 'spec/models/follow_recommendation_suppression_spec.rb' - - 'spec/models/follow_request_spec.rb' - - 'spec/models/follow_spec.rb' - - 'spec/models/home_feed_spec.rb' - - 'spec/models/identity_spec.rb' - - 'spec/models/import_spec.rb' - - 'spec/models/invite_spec.rb' - - 'spec/models/ip_block_spec.rb' - - 'spec/models/list_account_spec.rb' - - 'spec/models/list_spec.rb' - - 'spec/models/login_activity_spec.rb' - - 'spec/models/marker_spec.rb' - - 'spec/models/media_attachment_spec.rb' - - 'spec/models/mention_spec.rb' - - 'spec/models/mute_spec.rb' - - 'spec/models/notification_spec.rb' - - 'spec/models/one_time_key_spec.rb' - - 'spec/models/poll_spec.rb' - - 'spec/models/poll_vote_spec.rb' - - 'spec/models/preview_card_spec.rb' - - 'spec/models/preview_card_trend_spec.rb' - - 'spec/models/public_feed_spec.rb' - - 'spec/models/relay_spec.rb' - - 'spec/models/rule_spec.rb' - - 'spec/models/scheduled_status_spec.rb' - - 'spec/models/session_activation_spec.rb' - - 'spec/models/setting_spec.rb' - - 'spec/models/site_upload_spec.rb' - - 'spec/models/status_edit_spec.rb' - - 'spec/models/status_pin_spec.rb' - - 'spec/models/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/user_spec.rb' - - 'spec/models/web/push_subscription_spec.rb' - - 'spec/models/web/setting_spec.rb' - - 'spec/models/webauthn_credentials_spec.rb' - - 'spec/models/webhook_spec.rb' - -# Offense count: 6 -RSpec/RepeatedExample: - Exclude: - - 'spec/policies/status_policy_spec.rb' - -# Offense count: 6 -RSpec/RepeatedExampleGroupBody: - Exclude: - - 'spec/controllers/statuses_controller_spec.rb' - -# Offense count: 4 -RSpec/RepeatedExampleGroupDescription: - Exclude: - - 'spec/controllers/admin/reports/actions_controller_spec.rb' - - 'spec/policies/report_note_policy_spec.rb' - -# Offense count: 12 -RSpec/ScatteredSetup: - Exclude: - - 'spec/controllers/activitypub/followers_synchronizations_controller_spec.rb' - - 'spec/controllers/activitypub/outboxes_controller_spec.rb' - - 'spec/controllers/admin/disputes/appeals_controller_spec.rb' - - 'spec/controllers/auth/registrations_controller_spec.rb' - - 'spec/services/activitypub/process_account_service_spec.rb' - - 'spec/services/fetch_resource_service_spec.rb' - -# Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -RSpec/SharedContext: - Exclude: - - 'spec/services/unsuspend_account_service_spec.rb' - -# Offense count: 16 -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' - -# Offense count: 22 -RSpec/SubjectDeclaration: - Exclude: - - 'spec/controllers/admin/domain_blocks_controller_spec.rb' - - 'spec/controllers/api/v1/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' - -# Offense count: 5 -RSpec/SubjectStub: - Exclude: - - 'spec/services/unallow_domain_service_spec.rb' - - 'spec/validators/blacklisted_email_validator_spec.rb' - -# Offense count: 119 -# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. -RSpec/VerifiedDoubles: - Exclude: - - 'spec/controllers/admin/change_email_controller_spec.rb' - - 'spec/controllers/admin/confirmations_controller_spec.rb' - - 'spec/controllers/admin/disputes/appeals_controller_spec.rb' - - 'spec/controllers/admin/domain_allows_controller_spec.rb' - - 'spec/controllers/admin/domain_blocks_controller_spec.rb' - - 'spec/controllers/api/v1/reports_controller_spec.rb' - - 'spec/controllers/api/web/embeds_controller_spec.rb' - - 'spec/controllers/auth/sessions_controller_spec.rb' - - 'spec/controllers/disputes/appeals_controller_spec.rb' - - 'spec/controllers/settings/imports_controller_spec.rb' - - 'spec/helpers/statuses_helper_spec.rb' - - 'spec/lib/suspicious_sign_in_detector_spec.rb' - - 'spec/models/account/field_spec.rb' - - 'spec/models/session_activation_spec.rb' - - 'spec/models/setting_spec.rb' - - 'spec/services/account_search_service_spec.rb' - - 'spec/services/post_status_service_spec.rb' - - 'spec/services/search_service_spec.rb' - - 'spec/validators/blacklisted_email_validator_spec.rb' - - 'spec/validators/disallowed_hashtags_validator_spec.rb' - - 'spec/validators/email_mx_validator_spec.rb' - - 'spec/validators/follow_limit_validator_spec.rb' - - 'spec/validators/note_length_validator_spec.rb' - - 'spec/validators/poll_validator_spec.rb' - - 'spec/validators/status_length_validator_spec.rb' - - 'spec/validators/status_pin_validator_spec.rb' - - 'spec/validators/unique_username_validator_spec.rb' - - 'spec/validators/unreserved_username_validator_spec.rb' - - 'spec/validators/url_validator_spec.rb' - - 'spec/views/statuses/show.html.haml_spec.rb' - - 'spec/workers/activitypub/processing_worker_spec.rb' - - 'spec/workers/admin/domain_purge_worker_spec.rb' - - 'spec/workers/domain_block_worker_spec.rb' - - 'spec/workers/domain_clear_media_worker_spec.rb' - - 'spec/workers/feed_insert_worker_spec.rb' - - 'spec/workers/regeneration_worker_spec.rb' - -# Offense count: 19 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: ExpectedOrder, Include. -# ExpectedOrder: index, show, new, edit, create, update, destroy -# Include: app/controllers/**/*.rb -Rails/ActionOrder: - Exclude: - - 'app/controllers/admin/announcements_controller.rb' - - 'app/controllers/admin/roles_controller.rb' - - 'app/controllers/admin/rules_controller.rb' - - 'app/controllers/admin/warning_presets_controller.rb' - - 'app/controllers/admin/webhooks_controller.rb' - - 'app/controllers/api/v1/admin/domain_allows_controller.rb' - - 'app/controllers/api/v1/admin/domain_blocks_controller.rb' - - 'app/controllers/api/v1/admin/email_domain_blocks_controller.rb' - - 'app/controllers/api/v1/admin/ip_blocks_controller.rb' - - 'app/controllers/api/v1/filters_controller.rb' - - 'app/controllers/api/v1/media_controller.rb' - - 'app/controllers/api/v1/push/subscriptions_controller.rb' - - 'app/controllers/api/v2/filters/keywords_controller.rb' - - 'app/controllers/api/v2/filters/statuses_controller.rb' - - 'app/controllers/api/v2/filters_controller.rb' - - 'app/controllers/auth/registrations_controller.rb' - - 'app/controllers/filters_controller.rb' - - 'app/controllers/settings/applications_controller.rb' - - 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb' - -# Offense count: 7 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: Include. -# Include: app/models/**/*.rb -Rails/ActiveRecordCallbacksOrder: - Exclude: - - 'app/models/account.rb' - - 'app/models/account_conversation.rb' - - 'app/models/announcement_reaction.rb' - - 'app/models/block.rb' - - 'app/models/media_attachment.rb' - - 'app/models/session_activation.rb' - - 'app/models/status.rb' - -# Offense count: 4 # This cop supports unsafe autocorrection (--autocorrect-all). Rails/ApplicationController: Exclude: - 'app/controllers/health_controller.rb' - - 'app/controllers/well_known/host_meta_controller.rb' - - 'app/controllers/well_known/nodeinfo_controller.rb' - - 'app/controllers/well_known/webfinger_controller.rb' -# Offense count: 1 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: NilOrEmpty, NotPresent, UnlessPresent. -Rails/Blank: - Exclude: - - 'app/services/activitypub/fetch_remote_actor_service.rb' - -# Offense count: 35 -# 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' - -# Offense count: 7 -# This cop supports unsafe autocorrection (--autocorrect-all). -Rails/CompactBlank: - Exclude: - - 'app/helpers/application_helper.rb' - - 'app/helpers/statuses_helper.rb' - - 'app/models/concerns/attachmentable.rb' - - 'app/models/poll.rb' - - 'app/models/user.rb' - - 'app/services/import_service.rb' - -# Offense count: 3 -# This cop supports safe autocorrection (--autocorrect). -Rails/ContentTag: - Exclude: - - 'app/helpers/application_helper.rb' - - 'app/helpers/branding_helper.rb' - -# Offense count: 8 -# 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' - -# Offense count: 4 -# This cop supports unsafe autocorrection (--autocorrect-all). -Rails/DeprecatedActiveModelErrorsMethods: - Exclude: - - 'app/validators/ed25519_key_validator.rb' - - 'app/validators/ed25519_signature_validator.rb' - - 'lib/mastodon/accounts_cli.rb' - -# Offense count: 4 # This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Severity. Rails/DuplicateAssociation: Exclude: - 'app/serializers/activitypub/collection_serializer.rb' - 'app/serializers/activitypub/note_serializer.rb' -# Offense count: 76 -# Configuration parameters: EnforcedStyle. -# SupportedStyles: slashes, arguments -Rails/FilePath: - Exclude: - - 'app/lib/themes.rb' - - 'app/models/setting.rb' - - 'app/validators/reaction_validator.rb' - - 'db/migrate/20170716191202_add_hide_notifications_to_mute.rb' - - 'db/migrate/20170918125918_ids_to_bigints.rb' - - 'db/migrate/20171005171936_add_disabled_to_custom_emojis.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/20171130000000_add_embed_url_to_preview_cards.rb' - - 'db/migrate/20180615122121_add_autofollow_to_invites.rb' - - 'db/migrate/20180707154237_add_whole_word_to_custom_filter.rb' - - 'db/migrate/20180814171349_add_confidential_to_doorkeeper_application.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/20181127130500_identity_id_to_bigint.rb' - - 'db/migrate/20181127165847_add_show_replies_to_lists.rb' - - 'db/migrate/20190201012802_add_overwrite_to_imports.rb' - - 'db/migrate/20190306145741_add_lock_version_to_polls.rb' - - 'db/migrate/20190307234537_add_approved_to_users.rb' - - 'db/migrate/20191001213028_add_lock_version_to_account_stats.rb' - - 'db/migrate/20191212003415_increase_backup_size.rb' - - 'db/migrate/20200312144258_add_title_to_account_warning_presets.rb' - - 'db/migrate/20200620164023_add_fixed_lowercase_index_to_accounts.rb' - - 'db/migrate/20200917192924_add_notify_to_follows.rb' - - 'db/migrate/20201218054746_add_obfuscate_to_domain_blocks.rb' - - 'db/migrate/20210421121431_add_case_insensitive_btree_index_to_tags.rb' - - 'db/migrate/20211231080958_add_category_to_reports.rb' - - 'db/migrate/20220613110834_add_action_to_custom_filters.rb' - - 'db/post_migrate/20220307083603_optimize_null_index_conversations_uri.rb' - - 'db/post_migrate/20220310060545_optimize_null_index_statuses_in_reply_to_account_id.rb' - - 'db/post_migrate/20220310060556_optimize_null_index_statuses_in_reply_to_id.rb' - - 'db/post_migrate/20220310060614_optimize_null_index_media_attachments_scheduled_status_id.rb' - - 'db/post_migrate/20220310060626_optimize_null_index_media_attachments_shortcode.rb' - - 'db/post_migrate/20220310060641_optimize_null_index_users_reset_password_token.rb' - - 'db/post_migrate/20220310060653_optimize_null_index_users_created_by_application_id.rb' - - 'db/post_migrate/20220310060706_optimize_null_index_statuses_uri.rb' - - 'db/post_migrate/20220310060722_optimize_null_index_accounts_moved_to_account_id.rb' - - 'db/post_migrate/20220310060740_optimize_null_index_oauth_access_tokens_refresh_token.rb' - - 'db/post_migrate/20220310060750_optimize_null_index_accounts_url.rb' - - 'db/post_migrate/20220310060809_optimize_null_index_oauth_access_tokens_resource_owner_id.rb' - - 'db/post_migrate/20220310060833_optimize_null_index_announcement_reactions_custom_emoji_id.rb' - - 'db/post_migrate/20220310060854_optimize_null_index_appeals_approved_by_account_id.rb' - - 'db/post_migrate/20220310060913_optimize_null_index_account_migrations_target_account_id.rb' - - 'db/post_migrate/20220310060926_optimize_null_index_appeals_rejected_by_account_id.rb' - - 'db/post_migrate/20220310060939_optimize_null_index_list_accounts_follow_id.rb' - - 'db/post_migrate/20220310060959_optimize_null_index_web_push_subscriptions_access_token_id.rb' - - 'db/post_migrate/20220613110802_remove_whole_word_from_custom_filters.rb' - - 'db/post_migrate/20220613110903_remove_irreversible_from_custom_filters.rb' - - 'db/post_migrate/20220617202502_migrate_roles.rb' - - 'db/seeds.rb' - - 'db/seeds/03_roles.rb' - - 'lib/tasks/branding.rake' - - 'lib/tasks/emojis.rake' - - 'lib/tasks/repo.rake' - - 'spec/controllers/admin/custom_emojis_controller_spec.rb' - - 'spec/fabricators/custom_emoji_fabricator.rb' - - 'spec/fabricators/site_upload_fabricator.rb' - - 'spec/rails_helper.rb' - - 'spec/spec_helper.rb' - -# Offense count: 2 -# This cop supports safe autocorrection (--autocorrect). -Rails/FindById: - Exclude: - - 'app/controllers/api/v1/notifications_controller.rb' - - 'app/controllers/media_controller.rb' - -# Offense count: 6 # Configuration parameters: Include. # Include: app/models/**/*.rb Rails/HasAndBelongsToMany: @@ -1662,7 +231,6 @@ Rails/HasAndBelongsToMany: - 'app/models/status.rb' - 'app/models/tag.rb' -# Offense count: 15 # Configuration parameters: Include. # Include: app/models/**/*.rb Rails/HasManyOrHasOneDependent: @@ -1677,78 +245,18 @@ Rails/HasManyOrHasOneDependent: - 'app/models/user.rb' - 'app/models/web/push_subscription.rb' -# Offense count: 4 -# Configuration parameters: Include. -# Include: app/helpers/**/*.rb -Rails/HelperInstanceVariable: - Exclude: - - 'app/helpers/application_helper.rb' - - 'app/helpers/instance_helper.rb' - - 'app/helpers/jsonld_helper.rb' - -# Offense count: 3 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: Include. -# Include: spec/**/*, test/**/* -Rails/HttpPositionalArguments: - Exclude: - - 'spec/config/initializers/rack_attack_spec.rb' - -# Offense count: 7 -# Configuration parameters: Include. -# Include: spec/**/*.rb, test/**/*.rb -Rails/I18nLocaleAssignment: - Exclude: - - 'spec/controllers/auth/registrations_controller_spec.rb' - - 'spec/helpers/application_helper_spec.rb' - - 'spec/requests/localization_spec.rb' - -# Offense count: 6 Rails/I18nLocaleTexts: Exclude: - 'lib/tasks/mastodon.rake' - 'spec/helpers/flashes_helper_spec.rb' -# Offense count: 8 -# This cop supports unsafe autocorrection (--autocorrect-all). -Rails/IgnoredColumnsAssignment: - 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' - -# Offense count: 25 -# Configuration parameters: IgnoreScopes, Include. -# Include: app/models/**/*.rb -Rails/InverseOf: - Exclude: - - 'app/models/appeal.rb' - - 'app/models/concerns/account_interactions.rb' - - 'app/models/custom_emoji.rb' - - 'app/models/domain_block.rb' - - 'app/models/follow_recommendation.rb' - - 'app/models/instance.rb' - - 'app/models/notification.rb' - - 'app/models/status.rb' - - 'app/models/user_ip.rb' - -# Offense count: 13 # Configuration parameters: Include. # Include: app/controllers/**/*.rb, app/mailers/**/*.rb Rails/LexicallyScopedActionFilter: Exclude: - - 'app/controllers/admin/domain_blocks_controller.rb' - - 'app/controllers/admin/email_domain_blocks_controller.rb' - 'app/controllers/auth/passwords_controller.rb' - 'app/controllers/auth/registrations_controller.rb' - - 'app/controllers/auth/sessions_controller.rb' -# Offense count: 18 # This cop supports unsafe autocorrection (--autocorrect-all). Rails/NegateInclude: Exclude: @@ -1761,22 +269,15 @@ Rails/NegateInclude: - 'app/models/concerns/attachmentable.rb' - 'app/models/concerns/remotable.rb' - 'app/models/custom_filter.rb' - - 'app/models/webhook.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' -# Offense count: 2 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: Include. -# Include: app/**/*.rb, config/**/*.rb, db/**/*.rb, lib/**/*.rb -Rails/Output: +Rails/OutputSafety: Exclude: - - 'lib/mastodon/ip_blocks_cli.rb' + - 'config/initializers/simple_form.rb' -# Offense count: 9 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: Include. # Include: **/Rakefile, **/*.rake @@ -1789,38 +290,6 @@ Rails/RakeEnvironment: - 'lib/tasks/repo.rake' - 'lib/tasks/statistics.rake' -# Offense count: 29 -# 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' - -# Offense count: 10 -# This cop supports unsafe autocorrection (--autocorrect-all). -Rails/RootPathnameMethods: - Exclude: - - 'lib/mastodon/premailer_webpack_strategy.rb' - - 'lib/tasks/emojis.rake' - - 'lib/tasks/mastodon.rake' - - 'lib/tasks/repo.rake' - - 'spec/fabricators/custom_emoji_fabricator.rb' - - 'spec/fabricators/site_upload_fabricator.rb' - - 'spec/rails_helper.rb' - -# Offense count: 141 # 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: @@ -1866,33 +335,13 @@ Rails/SkipsModelValidations: - '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/cli.rb' - - 'lib/mastodon/accounts_cli.rb' - - 'lib/mastodon/maintenance_cli.rb' - - 'spec/controllers/api/v1/admin/accounts_controller_spec.rb' + - 'lib/mastodon/cli/accounts.rb' + - 'lib/mastodon/cli/main.rb' + - 'lib/mastodon/cli/maintenance.rb' - 'spec/lib/activitypub/activity/follow_spec.rb' - 'spec/services/follow_service_spec.rb' - 'spec/services/update_account_service_spec.rb' -# Offense count: 11 -# This cop supports unsafe autocorrection (--autocorrect-all). -Rails/SquishedSQLHeredocs: - Exclude: - - 'db/migrate/20170920024819_status_ids_to_timestamp_ids.rb' - - 'db/migrate/20180608213548_reject_following_blocked_users.rb' - - 'db/post_migrate/20190519130537_remove_boosts_widening_audience.rb' - - 'lib/mastodon/snowflake.rb' - - 'lib/tasks/tests.rake' - -# Offense count: 7 -Rails/TransactionExitStatement: - Exclude: - - 'app/lib/activitypub/activity/announce.rb' - - 'app/lib/activitypub/activity/create.rb' - - 'app/lib/activitypub/activity/delete.rb' - - 'app/services/activitypub/process_account_service.rb' - -# Offense count: 4 # Configuration parameters: Include. # Include: app/models/**/*.rb Rails/UniqueValidationWithoutIndex: @@ -1902,7 +351,6 @@ Rails/UniqueValidationWithoutIndex: - 'app/models/identity.rb' - 'app/models/webauthn_credential.rb' -# Offense count: 19 # Configuration parameters: Include. # Include: app/models/**/*.rb Rails/UnusedIgnoredColumns: @@ -1916,14 +364,6 @@ Rails/UnusedIgnoredColumns: - 'app/models/status_edit.rb' - 'app/models/user.rb' -# Offense count: 2 -# This cop supports unsafe autocorrection (--autocorrect-all). -Rails/WhereEquals: - Exclude: - - 'app/models/announcement.rb' - - 'app/models/status.rb' - -# Offense count: 61 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. # SupportedStyles: exists, where @@ -1953,42 +393,19 @@ Rails/WhereExists: - 'app/validators/vote_validator.rb' - 'app/workers/move_worker.rb' - 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb' - - 'lib/mastodon/email_domain_blocks_cli.rb' - 'lib/tasks/tests.rake' - - 'spec/controllers/api/v1/accounts/notes_controller_spec.rb' - - 'spec/controllers/api/v1/tags_controller_spec.rb' - 'spec/models/account_spec.rb' - 'spec/services/activitypub/process_collection_service_spec.rb' - - 'spec/services/post_status_service_spec.rb' - 'spec/services/purge_domain_service_spec.rb' - 'spec/services/unallow_domain_service_spec.rb' -# Offense count: 3 -# This cop supports unsafe autocorrection (--autocorrect-all). -Security/IoMethods: - Exclude: - - 'spec/controllers/admin/export_domain_allows_controller_spec.rb' - - 'spec/controllers/admin/export_domain_blocks_controller_spec.rb' - -# Offense count: 5 -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/CaseLikeIf: - Exclude: - - 'app/controllers/authorize_interactions_controller.rb' - - 'app/controllers/concerns/signature_verification.rb' - - 'app/helpers/jsonld_helper.rb' - - 'app/models/account.rb' - - 'app/services/resolve_url_service.rb' - -# Offense count: 445 -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: nested, compact -Style/ClassAndModuleChildren: - Enabled: false - -# Offense count: 2 # This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowOnConstant, AllowOnSelfClass. +Style/CaseEquality: + Exclude: + - 'config/initializers/trusted_proxies.rb' + +# This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: AllowedMethods, AllowedPatterns. # AllowedMethods: ==, equal?, eql? Style/ClassEqualityComparison: @@ -1996,54 +413,48 @@ Style/ClassEqualityComparison: - 'app/helpers/jsonld_helper.rb' - 'app/serializers/activitypub/outbox_serializer.rb' -# Offense count: 7 -Style/CombinableLoops: +Style/ClassVars: Exclude: - - 'app/models/form/custom_emoji_batch.rb' - - 'app/models/form/ip_block_batch.rb' + - 'config/initializers/devise.rb' -# Offense count: 5 -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/ConcatArrayLiterals: - Exclude: - - 'app/lib/feed_manager.rb' - -# Offense count: 1433 -# Configuration parameters: AllowedConstants. -Style/Documentation: - Enabled: false - -# Offense count: 10 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowedVars. Style/FetchEnvVar: Exclude: - - 'app/helpers/application_helper.rb' - 'app/lib/redis_configuration.rb' - 'app/lib/translation_service.rb' + - 'config/environments/development.rb' + - 'config/environments/production.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/paperclip.rb' + - 'config/initializers/vapid.rb' - 'lib/mastodon/premailer_webpack_strategy.rb' - 'lib/mastodon/redis_config.rb' - 'lib/tasks/repo.rake' - 'spec/features/profile_spec.rb' -# Offense count: 15 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns. # SupportedStyles: annotated, template, unannotated +# AllowedMethods: redirect Style/FormatStringToken: Exclude: - 'app/models/privacy_policy.rb' - - 'lib/mastodon/maintenance_cli.rb' + - 'config/initializers/devise.rb' - 'lib/paperclip/color_extractor.rb' -# Offense count: 713 # This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: always, always_true, never -Style/FrozenStringLiteralComment: - Enabled: false +Style/GlobalStdStream: + Exclude: + - 'config/boot.rb' + - 'config/environments/development.rb' + - 'config/environments/production.rb' -# Offense count: 34 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. Style/GuardClause: @@ -2053,7 +464,6 @@ Style/GuardClause: - 'app/controllers/auth/passwords_controller.rb' - 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb' - 'app/lib/activitypub/activity/block.rb' - - 'app/lib/connection_pool/shared_connection_pool.rb' - 'app/lib/request.rb' - 'app/lib/request_pool.rb' - 'app/lib/webfinger.rb' @@ -2070,17 +480,17 @@ Style/GuardClause: - 'app/workers/redownload_header_worker.rb' - '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/mastodon/accounts_cli.rb' - - 'lib/mastodon/maintenance_cli.rb' - - 'lib/mastodon/media_cli.rb' + - 'lib/mastodon/cli/accounts.rb' + - 'lib/mastodon/cli/maintenance.rb' + - 'lib/mastodon/cli/media.rb' - 'lib/paperclip/attachment_extensions.rb' - 'lib/tasks/repo.rake' -# Offense count: 13 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: braces, no_braces @@ -2088,7 +498,6 @@ Style/HashAsLastArrayItem: Exclude: - 'app/controllers/admin/statuses_controller.rb' - 'app/controllers/api/v1/statuses_controller.rb' - - 'app/models/account.rb' - 'app/models/concerns/account_counters.rb' - 'app/models/concerns/status_threading_concern.rb' - 'app/models/status.rb' @@ -2096,35 +505,19 @@ Style/HashAsLastArrayItem: - 'app/services/notify_service.rb' - 'db/migrate/20181024224956_migrate_account_conversations.rb' -# Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowSplatArgument. -Style/HashConversion: - Exclude: - - 'app/services/import_service.rb' - -# Offense count: 12 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, EnforcedShorthandSyntax, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols. -# SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys -# SupportedShorthandSyntax: always, never, either, consistent -Style/HashSyntax: - Exclude: - - 'app/helpers/application_helper.rb' - - 'app/models/media_attachment.rb' - - 'lib/terrapin/multi_pipe_extensions.rb' - - 'spec/controllers/admin/reports/actions_controller_spec.rb' - - 'spec/controllers/admin/statuses_controller_spec.rb' - - 'spec/controllers/concerns/signature_verification_spec.rb' - -# Offense count: 3 # This cop supports unsafe autocorrection (--autocorrect-all). Style/HashTransformValues: Exclude: - 'app/serializers/rest/web_push_subscription_serializer.rb' - 'app/services/import_service.rb' -# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +Style/IfUnlessModifier: + Exclude: + - 'config/environments/production.rb' + - 'config/initializers/devise.rb' + - 'config/initializers/ffmpeg.rb' + # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: InverseMethods, InverseBlocks. Style/InverseMethods: @@ -2133,37 +526,40 @@ Style/InverseMethods: - 'app/services/update_account_service.rb' - 'spec/controllers/activitypub/replies_controller_spec.rb' -# Offense count: 1 +# 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: - 'app/models/status.rb' -# Offense count: 17 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. # SupportedStyles: literals, strict Style/MutableConstant: Exclude: - - 'app/lib/link_details_extractor.rb' - - 'app/models/account.rb' - - 'app/models/custom_emoji.rb' - 'app/models/tag.rb' - - 'app/services/account_search_service.rb' - 'app/services/delete_account_service.rb' - - 'app/services/fetch_link_card_service.rb' - - 'app/services/resolve_url_service.rb' - - 'app/validators/html_validator.rb' - - 'lib/mastodon/snowflake.rb' - - 'spec/controllers/api/base_controller_spec.rb' + - 'lib/mastodon/migration_warning.rb' + +# This cop supports safe autocorrection (--autocorrect). +Style/NilLambda: + Exclude: + - 'config/initializers/paperclip.rb' -# Offense count: 10 # Configuration parameters: AllowedMethods. # AllowedMethods: respond_to_missing? Style/OptionalBooleanParameter: Exclude: - 'app/helpers/admin/account_moderation_notes_helper.rb' - 'app/helpers/jsonld_helper.rb' + - 'app/lib/admin/system_check/message.rb' - 'app/lib/request.rb' - 'app/lib/webfinger.rb' - 'app/services/block_domain_service.rb' @@ -2172,87 +568,52 @@ Style/OptionalBooleanParameter: - 'app/workers/unfollow_follow_worker.rb' - 'lib/mastodon/redis_config.rb' -# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: PreferredDelimiters. +Style/PercentLiteralDelimiters: + Exclude: + - 'config/deploy.rb' + - 'config/initializers/doorkeeper.rb' + # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. # SupportedStyles: short, verbose Style/PreferredHashMethods: Exclude: - - 'spec/support/matchers/model/model_have_error_on_field.rb' + - 'config/initializers/paperclip.rb' + +# This cop supports safe autocorrection (--autocorrect). +Style/RedundantConstantBase: + Exclude: + - 'config/environments/production.rb' + - 'config/initializers/sidekiq.rb' -# Offense count: 5 # This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: Methods. -Style/RedundantArgument: +# Configuration parameters: SafeForConstants. +Style/RedundantFetchBlock: Exclude: - - 'app/controllers/concerns/signature_verification.rb' - - 'app/helpers/application_helper.rb' - - 'lib/tasks/emojis.rake' + - 'config/initializers/1_hosts.rb' + - 'config/initializers/chewy.rb' + - 'config/initializers/devise.rb' + - 'config/initializers/paperclip.rb' + - 'config/puma.rb' -# Offense count: 16 # This cop supports safe autocorrection (--autocorrect). -Style/RedundantRegexpCharacterClass: +# Configuration parameters: AllowMultipleReturnValues. +Style/RedundantReturn: Exclude: - - 'app/lib/link_details_extractor.rb' - - 'app/lib/tag_manager.rb' - - 'app/models/domain_allow.rb' - - 'app/models/domain_block.rb' - - 'app/services/fetch_oembed_service.rb' - - 'lib/tasks/emojis.rake' - - 'lib/tasks/mastodon.rake' + - 'app/controllers/api/v1/directories_controller.rb' + - 'app/controllers/auth/confirmations_controller.rb' + - 'app/lib/ostatus/tag_manager.rb' + - 'app/models/form/import.rb' -# Offense count: 10 -# This cop supports safe autocorrection (--autocorrect). -Style/RedundantRegexpEscape: - Exclude: - - 'app/lib/webfinger_resource.rb' - - 'app/models/account.rb' - - 'app/models/tag.rb' - - 'app/services/fetch_link_card_service.rb' - - 'lib/paperclip/color_extractor.rb' - - 'lib/tasks/mastodon.rake' - -# Offense count: 19 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, AllowInnerSlashes. -# SupportedStyles: slashes, percent_r, mixed -Style/RegexpLiteral: - Exclude: - - 'app/lib/link_details_extractor.rb' - - 'app/lib/permalink_redirector.rb' - - 'app/lib/plain_text_formatter.rb' - - 'app/lib/tag_manager.rb' - - 'app/lib/text_formatter.rb' - - 'app/models/account.rb' - - 'app/models/domain_allow.rb' - - 'app/models/domain_block.rb' - - 'app/models/site_upload.rb' - - 'app/models/tag.rb' - - 'app/services/backup_service.rb' - - 'app/services/fetch_oembed_service.rb' - - 'app/services/search_service.rb' - - 'lib/mastodon/premailer_webpack_strategy.rb' - - 'lib/tasks/mastodon.rake' - -# Offense count: 2 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength. # AllowedMethods: present?, blank?, presence, try, try! Style/SafeNavigation: Exclude: - 'app/models/concerns/account_finder_concern.rb' - - 'app/models/status.rb' -# Offense count: 5 -# 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' - - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb' - -# Offense count: 2 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: only_raise, only_fail, semantic @@ -2261,175 +622,76 @@ Style/SignalException: - 'lib/devise/two_factor_ldap_authenticatable.rb' - 'lib/devise/two_factor_pam_authenticatable.rb' -# Offense count: 3 # This cop supports unsafe autocorrection (--autocorrect-all). Style/SingleArgumentDig: Exclude: - 'lib/webpacker/manifest_extensions.rb' -# Offense count: 14 -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/SlicingWithRange: +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: require_parentheses, require_no_parentheses +Style/StabbyLambdaParentheses: Exclude: - - 'app/lib/emoji_formatter.rb' - - 'app/lib/text_formatter.rb' - - 'app/lib/toc_generator.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' + - 'config/environments/production.rb' + - 'config/initializers/content_security_policy.rb' + +# This cop supports safe autocorrection (--autocorrect). +Style/StderrPuts: + Exclude: + - 'config/boot.rb' -# Offense count: 25 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: Mode. Style/StringConcatenation: Exclude: - - 'app/lib/activitypub/case_transform.rb' - - 'app/lib/validation_error_formatter.rb' - - 'app/services/backup_service.rb' - - 'app/services/fetch_link_card_service.rb' - - 'lib/mastodon/emoji_cli.rb' - - 'lib/mastodon/redis_config.rb' - - 'lib/mastodon/snowflake.rb' - - 'lib/paperclip/gif_transcoder.rb' - - 'lib/paperclip/type_corrector.rb' - - 'spec/controllers/api/v1/apps_controller_spec.rb' - - 'spec/controllers/api/v1/streaming_controller_spec.rb' - - 'spec/validators/disallowed_hashtags_validator_spec.rb' - - 'spec/workers/web/push_notification_worker_spec.rb' + - 'config/initializers/paperclip.rb' -# Offense count: 272 # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, MinSize. -# SupportedStyles: percent, brackets -Style/SymbolArray: +# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. +# SupportedStyles: single_quotes, double_quotes +Style/StringLiterals: Exclude: - - 'app/controllers/accounts_controller.rb' - - 'app/controllers/activitypub/replies_controller.rb' - - 'app/controllers/admin/accounts_controller.rb' - - 'app/controllers/admin/announcements_controller.rb' - - 'app/controllers/admin/domain_blocks_controller.rb' - - 'app/controllers/admin/email_domain_blocks_controller.rb' - - 'app/controllers/admin/relationships_controller.rb' - - 'app/controllers/admin/relays_controller.rb' - - 'app/controllers/admin/roles_controller.rb' - - 'app/controllers/admin/rules_controller.rb' - - 'app/controllers/admin/statuses_controller.rb' - - 'app/controllers/admin/trends/statuses_controller.rb' - - 'app/controllers/admin/warning_presets_controller.rb' - - 'app/controllers/admin/webhooks_controller.rb' - - 'app/controllers/api/v1/accounts/credentials_controller.rb' - - 'app/controllers/api/v1/accounts_controller.rb' - - 'app/controllers/api/v1/admin/accounts_controller.rb' - - 'app/controllers/api/v1/admin/canonical_email_blocks_controller.rb' - - 'app/controllers/api/v1/admin/domain_allows_controller.rb' - - 'app/controllers/api/v1/admin/domain_blocks_controller.rb' - - 'app/controllers/api/v1/admin/email_domain_blocks_controller.rb' - - 'app/controllers/api/v1/admin/ip_blocks_controller.rb' - - 'app/controllers/api/v1/admin/reports_controller.rb' - - 'app/controllers/api/v1/crypto/deliveries_controller.rb' - - 'app/controllers/api/v1/crypto/keys/claims_controller.rb' - - 'app/controllers/api/v1/crypto/keys/uploads_controller.rb' - - 'app/controllers/api/v1/featured_tags_controller.rb' - - 'app/controllers/api/v1/filters_controller.rb' - - 'app/controllers/api/v1/lists_controller.rb' - - 'app/controllers/api/v1/notifications_controller.rb' - - 'app/controllers/api/v1/push/subscriptions_controller.rb' - - 'app/controllers/api/v1/scheduled_statuses_controller.rb' - - 'app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb' - - 'app/controllers/api/v1/statuses_controller.rb' - - 'app/controllers/api/v2/filters/keywords_controller.rb' - - 'app/controllers/api/v2/filters/statuses_controller.rb' - - 'app/controllers/api/v2/filters_controller.rb' - - 'app/controllers/api/web/push_subscriptions_controller.rb' - - 'app/controllers/application_controller.rb' - - 'app/controllers/auth/registrations_controller.rb' - - 'app/controllers/filters_controller.rb' - - 'app/controllers/settings/applications_controller.rb' - - 'app/controllers/settings/featured_tags_controller.rb' - - 'app/controllers/settings/profiles_controller.rb' - - 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb' - - 'app/controllers/statuses_controller.rb' - - 'app/lib/feed_manager.rb' - - 'app/models/account.rb' - - 'app/models/account_filter.rb' - - 'app/models/admin/status_filter.rb' - - 'app/models/announcement.rb' - - 'app/models/concerns/ldap_authenticable.rb' - - 'app/models/concerns/status_threading_concern.rb' - - 'app/models/custom_filter.rb' - - 'app/models/domain_block.rb' - - 'app/models/import.rb' - - 'app/models/list.rb' - - 'app/models/media_attachment.rb' - - 'app/models/preview_card.rb' - - 'app/models/relay.rb' - - 'app/models/report.rb' - - 'app/models/site_upload.rb' - - 'app/models/status.rb' - - 'app/serializers/initial_state_serializer.rb' - - 'app/serializers/rest/notification_serializer.rb' - - 'db/migrate/20160220174730_create_accounts.rb' - - 'db/migrate/20160221003621_create_follows.rb' - - 'db/migrate/20160223171800_create_favourites.rb' - - 'db/migrate/20160224223247_create_mentions.rb' - - 'db/migrate/20160314164231_add_owner_to_application.rb' - - 'db/migrate/20160316103650_add_missing_indices.rb' - - 'db/migrate/20160926213048_remove_owner_from_application.rb' - - 'db/migrate/20161003145426_create_blocks.rb' - - 'db/migrate/20161006213403_rails_settings_migration.rb' - - 'db/migrate/20161105130633_create_statuses_tags_join_table.rb' - - 'db/migrate/20161119211120_create_notifications.rb' - - 'db/migrate/20161128103007_create_subscriptions.rb' - - 'db/migrate/20161222204147_create_follow_requests.rb' - - 'db/migrate/20170112154826_migrate_settings.rb' - - 'db/migrate/20170301222600_create_mutes.rb' - - 'db/migrate/20170406215816_add_notifications_and_favourites_indices.rb' - - 'db/migrate/20170424003227_create_account_domain_blocks.rb' - - 'db/migrate/20170427011934_re_add_owner_to_application.rb' - - 'db/migrate/20170507141759_optimize_index_subscriptions.rb' - - 'db/migrate/20170508230434_create_conversation_mutes.rb' - - 'db/migrate/20170720000000_add_index_favourites_on_account_id_and_id.rb' - - 'db/migrate/20170823162448_create_status_pins.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/20170917153509_create_custom_emojis.rb' - - 'db/migrate/20170918125918_ids_to_bigints.rb' - - 'db/migrate/20171116161857_create_list_accounts.rb' - - 'db/migrate/20171122120436_add_index_account_and_reblog_of_id_to_statuses.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/20171226094803_more_faster_index_on_notifications.rb' - - '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/20180808175627_create_account_pins.rb' - - 'db/migrate/20180831171112_create_bookmarks.rb' - - 'db/migrate/20180929222014_create_account_conversations.rb' - - 'db/migrate/20181007025445_create_pghero_space_stats.rb' - - 'db/migrate/20181203003808_create_accounts_tags_join_table.rb' - - 'db/migrate/20190316190352_create_account_identity_proofs.rb' - - 'db/migrate/20190511134027_add_silenced_at_suspended_at_to_accounts.rb' - - 'db/migrate/20190820003045_update_statuses_index.rb' - - 'db/migrate/20190823221802_add_local_index_to_statuses.rb' - - 'db/migrate/20190904222339_create_markers.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/20200628133322_create_account_notes.rb' - - 'db/migrate/20200917222316_add_index_notifications_on_type.rb' - - 'db/migrate/20210425135952_add_index_on_media_attachments_account_id_status_id.rb' - - 'db/migrate/20220714171049_create_tag_follows.rb' - - 'db/migrate/20221021055441_add_index_featured_tags_on_account_id_and_tag_id.rb' - - 'db/post_migrate/20190511152737_remove_suspended_silenced_account_fields.rb' - - 'db/post_migrate/20200917222734_remove_index_notifications_on_account_activity.rb' - - 'spec/controllers/api/v1/streaming_controller_spec.rb' - - 'spec/controllers/api/v2/admin/accounts_controller_spec.rb' - - 'spec/controllers/concerns/signature_verification_spec.rb' - - 'spec/fabricators/notification_fabricator.rb' - - 'spec/models/public_feed_spec.rb' + - 'config/environments/production.rb' + - 'config/initializers/backtrace_silencers.rb' + - 'config/initializers/http_client_proxy.rb' + - 'config/initializers/rack_attack.rb' + - '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/3_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 +Style/TrailingCommaInArguments: + Exclude: + - 'config/initializers/paperclip.rb' + +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyleForMultiline. +# SupportedStylesForMultiline: comma, consistent_comma, no_comma +Style/TrailingCommaInHashLiteral: + Exclude: + - 'config/environments/production.rb' + - 'config/environments/test.rb' + +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, MinSize, WordRegex. +# SupportedStyles: percent, brackets +Style/WordArray: + Exclude: + - 'app/helpers/languages_helper.rb' + - 'spec/controllers/settings/imports_controller_spec.rb' + - 'spec/models/form/import_spec.rb' diff --git a/.ruby-version b/.ruby-version index e4604e3afd0..be94e6f53db 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.1 +3.2.2 diff --git a/.yarnclean b/.yarnclean index 0cc2b50d7be..21eb734a6c6 100644 --- a/.yarnclean +++ b/.yarnclean @@ -44,3 +44,6 @@ Gruntfile.js # 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/AUTHORS.md b/AUTHORS.md index 18b9f2d7086..78cc37a17b9 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/Aptfile b/Aptfile index 8f5bb72a252..5e033f13650 100644 --- a/Aptfile +++ b/Aptfile @@ -1,4 +1,5 @@ ffmpeg +libopenblas0-pthread libpq-dev libxdamage1 libxfixes3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b826fb14ac..6f775fcfa8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,515 @@ 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 + +- Add fallback redirection when getting a webfinger query `LOCAL_DOMAIN@LOCAL_DOMAIN` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23600)) + +### Changed + +- Change OpenGraph-based embeds to allow fullscreen ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25058)) +- Change AccessTokensVacuum to also delete expired tokens ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24868)) +- Change profile updates to be sent to recently-mentioned servers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24852)) +- Change automatic post deletion thresholds and load detection ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24614)) +- Change `/api/v1/statuses/:id/history` to always return at least one item ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25510)) +- Change auto-linking to allow carets in URL query params ([renchap](https://github.com/mastodon/mastodon/pull/25216)) + +### Removed + +- Remove invalid `X-Frame-Options: ALLOWALL` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25070)) + +### Fixed + +- Fix wrong view being displayed when a webhook fails validation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25464)) +- Fix soft-deleted post cleanup scheduler overwhelming the streaming server ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25519)) +- Fix incorrect pagination headers in `/api/v2/admin/accounts` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25477)) +- 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 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)) +- Fix “Authorized applications” inefficiently and incorrectly getting last use date ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25060)) +- Fix “Authorized applications” crashing when listing apps with certain admin API scopes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25713)) +- Fix multiple N+1s in ConversationsController ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25134), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25399), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25499)) +- Fix user archive takeouts when using OpenStack Swift ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24431)) +- Fix searching for remote content by URL not working under certain conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25637)) +- Fix inefficiencies in indexing content for search ([VyrCossont](https://github.com/mastodon/mastodon/pull/24285), [VyrCossont](https://github.com/mastodon/mastodon/pull/24342)) + +### Security + +- Add finer permission requirements for managing webhooks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25463)) +- Update dependencies +- Add hardening headers for user-uploaded files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25756)) +- Fix verified links possibly hiding important parts of the URL (CVE-2023-36462) +- Fix timeout handling of outbound HTTP requests (CVE-2023-36461) +- Fix arbitrary file creation through media processing (CVE-2023-36460) +- Fix possible XSS in preview cards (CVE-2023-36459) + +## [4.1.2] - 2023-04-04 + +### Fixed + +- Fix crash in `tootctl` commands making use of parallelization when Elasticsearch is enabled ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24182), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24377)) +- Fix crash in `db:setup` when Elasticsearch is enabled ([rrgeorge](https://github.com/mastodon/mastodon/pull/24302)) +- Fix user archive takeout when using OpenStack Swift or S3 providers with no ACL support ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24200)) +- Fix invalid/expired invites being processed on sign-up ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24337)) + +### Security + +- Update Ruby to 3.0.6 due to ReDoS vulnerabilities ([saizai](https://github.com/mastodon/mastodon/pull/24334)) +- Fix unescaped user input in LDAP query ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24379)) + +## [4.1.1] - 2023-03-16 + +### Added + +- Add redirection from paths with url-encoded `@` to their decoded form ([thijskh](https://github.com/mastodon/mastodon/pull/23593)) +- Add `lang` attribute to native language names in language picker in Web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23749)) +- Add headers to outgoing mails to avoid auto-replies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23597)) +- Add support for refreshing many accounts at once with `tootctl accounts refresh` ([9p4](https://github.com/mastodon/mastodon/pull/23304)) +- Add confirmation modal when clicking to edit a post with a non-empty compose form ([PauloVilarinho](https://github.com/mastodon/mastodon/pull/23936)) +- Add support for the HAproxy PROXY protocol through the `PROXY_PROTO_V1` environment variable ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24064)) +- Add `SENDFILE_HEADER` environment variable ([Gargron](https://github.com/mastodon/mastodon/pull/24123)) +- Add cache headers to static files served through Rails ([Gargron](https://github.com/mastodon/mastodon/pull/24120)) + +### Changed + +- Increase contrast of upload progress bar background ([toolmantim](https://github.com/mastodon/mastodon/pull/23836)) +- Change post auto-deletion throttling constants to better scale with server size ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23320)) +- Change order of bookmark and favourite sidebar entries in single-column UI for consistency ([TerryGarcia](https://github.com/mastodon/mastodon/pull/23701)) +- Change `ActivityPub::DeliveryWorker` retries to be spread out more ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21956)) + +### Fixed + +- Fix “Remove all followers from the selected domains” also removing follows and notifications ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23805)) +- Fix streaming metrics format ([emilweth](https://github.com/mastodon/mastodon/pull/23519), [emilweth](https://github.com/mastodon/mastodon/pull/23520)) +- Fix case-sensitive check for previously used hashtags in hashtag autocompletion ([deanveloper](https://github.com/mastodon/mastodon/pull/23526)) +- Fix focus point of already-attached media not saving after edit ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23566)) +- Fix sidebar behavior in settings/admin UI on mobile ([wxt2005](https://github.com/mastodon/mastodon/pull/23764)) +- Fix inefficiency when searching accounts per username in admin interface ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23801)) +- Fix duplicate “Publish” button on mobile ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23804)) +- Fix server error when failing to follow back followers from `/relationships` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23787)) +- Fix server error when attempting to display the edit history of a trendable post in the admin interface ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23574)) +- Fix `tootctl accounts migrate` crashing because of a typo ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23567)) +- Fix original account being unfollowed on migration before the follow request to the new account could be sent ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21957)) +- Fix the “Back” button in column headers sometimes leaving Mastodon ([c960657](https://github.com/mastodon/mastodon/pull/23953)) +- Fix pgBouncer resetting application name on every transaction ([Gargron](https://github.com/mastodon/mastodon/pull/23958)) +- Fix unconfirmed accounts being counted as active users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23803)) +- Fix `/api/v1/streaming` sub-paths not being redirected ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23988)) +- Fix drag'n'drop upload area text that spans multiple lines not being centered ([vintprox](https://github.com/mastodon/mastodon/pull/24029)) +- Fix sidekiq jobs not triggering Elasticsearch index updates ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24046)) +- Fix tags being unnecessarily stripped from plain-text short site description ([c960657](https://github.com/mastodon/mastodon/pull/23975)) +- Fix HTML entities not being un-escaped in extracted plain-text from remote posts ([c960657](https://github.com/mastodon/mastodon/pull/24019)) +- Fix dashboard crash on ElasticSearch server error ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23751)) +- Fix incorrect post links in strikes when the account is remote ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23611)) +- Fix misleading error code when receiving invalid WebAuthn credentials ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23568)) +- Fix duplicate mails being sent when the SMTP server is too slow to close the connection ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23750)) + +### Security + +- Change user backups to use expiring URLs for download when possible ([Gargron](https://github.com/mastodon/mastodon/pull/24136)) +- Add warning for object storage misconfiguration ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24137)) + ## [4.1.0] - 2023-02-10 ### Added @@ -22,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)) @@ -429,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/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 97ed96772d6..c88a0d93855 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,45 +2,131 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to creating a positive environment include: +Examples of behavior that contributes to a positive environment for our +community include: -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community -- Showing empathy towards other community members +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -- The use of sexualized language or imagery and unwelcome sexual attention or advances -- Trolling, insulting/derogatory comments, and personal or political attacks +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment -- Publishing others' private information, such as a physical or electronic address, without explicit permission -- Other conduct which could reasonably be considered inappropriate in a professional setting +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. ## Scope -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at eugen@zeonfederated.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[hello@joinmastodon.org](mailto:hello@joinmastodon.org). +All complaints will be reviewed and investigated promptly and fairly. -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [https://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. -[homepage]: https://contributor-covenant.org -[version]: https://contributor-covenant.org/version/1/4/ +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d67b21ee585..c1a5fef7983 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,8 +41,6 @@ It is not always possible to phrase every change in such a manner, but it is des - Code style rules (rubocop, eslint) - Normalization of locale files (i18n-tasks) -**Note**: You may need to log in and authorise the GitHub account your fork of this repository belongs to with CircleCI to enable some of the automated checks to run. - ## Documentation The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to mastodon/documentation](https://github.com/mastodon/documentation). diff --git a/Capfile b/Capfile deleted file mode 100644 index 86efa5bacf8..00000000000 --- 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 c2b18ce8863..600336de92a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # syntax=docker/dockerfile:1.4 -# This needs to be bullseye-slim because the Ruby image is built on bullseye-slim -ARG NODE_VERSION="16.19-bullseye-slim" +# This needs to be bookworm-slim because the Ruby image is built on bookworm-slim +ARG NODE_VERSION="20.8-bookworm-slim" -FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.1-slim as ruby +FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim as ruby FROM node:${NODE_VERSION} as build COPY --link --from=ruby /opt/ruby /opt/ruby @@ -17,18 +17,18 @@ COPY Gemfile* package.json yarn.lock /opt/mastodon/ # hadolint ignore=DL3008 RUN apt-get update && \ + apt-get -yq dist-upgrade && \ apt-get install -y --no-install-recommends build-essential \ - ca-certificates \ git \ libicu-dev \ - libidn11-dev \ + libidn-dev \ libpq-dev \ libjemalloc-dev \ zlib1g-dev \ libgdbm-dev \ libgmp-dev \ libssl-dev \ - libyaml-0-2 \ + libyaml-dev \ ca-certificates \ libreadline8 \ python3 \ @@ -37,11 +37,15 @@ RUN apt-get update && \ bundle config set --local without 'development test' && \ bundle config set silence_root_warning true && \ bundle install -j"$(nproc)" && \ - yarn install --pure-lockfile --network-timeout 600000 && \ + 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_PRERELEASE="" +ARG MASTODON_VERSION_METADATA="" + ARG UID="991" ARG GID="991" @@ -52,7 +56,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] ENV DEBIAN_FRONTEND="noninteractive" \ PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" -# Ignoreing these here since we don't want to pin any versions and the Debian image removes apt-get content after use +# 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 && \ @@ -61,13 +65,13 @@ RUN apt-get update && \ apt-get -y --no-install-recommends install whois \ wget \ procps \ - libssl1.1 \ + libssl3 \ libpq5 \ imagemagick \ ffmpeg \ libjemalloc2 \ - libicu67 \ - libidn11 \ + libicu72 \ + libidn12 \ libyaml-0-2 \ file \ ca-certificates \ @@ -85,7 +89,9 @@ COPY --chown=mastodon:mastodon --from=build /opt/mastodon /opt/mastodon ENV RAILS_ENV="production" \ NODE_ENV="production" \ RAILS_SERVE_STATIC_FILES="true" \ - BIND="0.0.0.0" + BIND="0.0.0.0" \ + MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \ + MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" # Set the run user USER mastodon diff --git a/FEDERATION.md b/FEDERATION.md index cd1957cbd1e..e3721d7241e 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -27,4 +27,5 @@ More information on HTTP Signatures, as well as examples, can be found here: htt - Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld - Bearcaps: https://docs.joinmastodon.org/spec/bearcaps/ -- Followers collection synchronization: https://git.activitypub.dev/ActivityPubDev/Fediverse-Enhancement-Proposals/src/branch/main/feps/fep-8fcf.md +- Followers collection synchronization: https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md +- Search indexing consent for actors: https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md diff --git a/Gemfile b/Gemfile index 0fca82cea3e..c935b5410f7 100644 --- a/Gemfile +++ b/Gemfile @@ -1,27 +1,24 @@ # frozen_string_literal: true source 'https://rubygems.org' -ruby '>= 2.7.0', '< 3.3.0' +ruby '>= 3.0.0' -gem 'pkg-config', '~> 1.5' -gem 'rexml', '~> 3.2' - -gem 'puma', '~> 5.6' -gem 'rails', '~> 6.1.7' +gem 'puma', '~> 6.3' +gem 'rails', '~> 7.1.1' gem 'sprockets', '~> 3.7.2' gem 'thor', '~> 1.2' -gem 'rack', '~> 2.2.6' +gem 'rack', '~> 2.2.7' gem 'haml-rails', '~>2.0' -gem 'pg', '~> 1.4' -gem 'makara', '~> 0.5' +gem 'pg', '~> 1.5' gem 'pghero' gem 'dotenv-rails', '~> 2.8' -gem 'aws-sdk-s3', '~> 1.119', require: false +gem 'aws-sdk-s3', '~> 1.123', require: false gem 'fog-core', '<= 2.4.0' gem 'fog-openstack', '~> 0.3', require: false -gem 'kt-paperclip', '~> 7.1', github: 'kreeti/kt-paperclip', ref: '11abf222dc31bff71160a1d138b445214f434b2b' +gem 'kt-paperclip', '~> 7.2' +gem 'md-paperclip-azure', '~> 2.2', require: false gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' @@ -29,20 +26,23 @@ gem 'addressable', '~> 2.8' gem 'bootsnap', '~> 1.16.0', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' -gem 'chewy', '~> 7.2' -gem 'devise', '~> 4.8' -gem 'devise-two-factor', '~> 4.0' +gem 'chewy', '~> 7.3' +gem 'devise', '~> 4.9' +gem 'devise-two-factor', '~> 4.1' group :pam_authentication, optional: true do gem 'devise_pam_authenticatable2', '~> 9.2' end -gem 'net-ldap', '~> 0.17' -gem 'omniauth-cas', '~> 2.0' -gem 'omniauth-saml', '~> 1.10' -gem 'omniauth_openid_connect', '~> 0.6.0' -gem 'omniauth', '~> 1.9' -gem 'omniauth-rails_csrf_protection', '~> 0.1' +gem 'net-ldap', '~> 0.18' + +# TODO: Point back at released omniauth-cas gem when PR merged +# https://github.com/dlindahl/omniauth-cas/pull/68 +gem 'omniauth-cas', github: 'stanhu/omniauth-cas', ref: '4211e6d05941b4a981f9a36b49ec166cecd0e271' +gem 'omniauth-saml', '~> 2.0' +gem 'omniauth_openid_connect', '~> 0.6.1' +gem 'omniauth', '~> 2.0' +gem 'omniauth-rails_csrf_protection', '~> 1.0' gem 'color_diff', '~> 0.1' gem 'discard', '~> 1.2' @@ -59,9 +59,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 'nokogiri', '~> 1.14' -gem 'nsa', '~> 0.2' +gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' +gem 'nokogiri', '~> 1.15' +gem 'nsa', github: 'jhawthorn/nsa', ref: 'e020fcc3a54d993ab45b7194d89ab720296c111b' gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' @@ -70,14 +70,14 @@ gem 'public_suffix', '~> 5.0' gem 'pundit', '~> 2.3' gem 'premailer-rails' gem 'rack-attack', '~> 6.6' -gem 'rack-cors', '~> 1.1', require: 'rack/cors' -gem 'rails-i18n', '~> 6.0' +gem 'rack-cors', '~> 2.0', require: 'rack/cors' +gem 'rails-i18n', '~> 7.0' gem 'rails-settings-cached', '~> 0.6', git: 'https://github.com/mastodon/rails-settings-cached.git', branch: 'v0.6.6-aliases-true' gem 'redcarpet', '~> 3.6' gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis'] gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' -gem 'rqrcode', '~> 2.1' -gem 'ruby-progressbar', '~> 1.11' +gem 'rqrcode', '~> 2.2' +gem 'ruby-progressbar', '~> 1.13' gem 'sanitize', '~> 6.0' gem 'scenic', '~> 1.7' gem 'sidekiq', '~> 6.5' @@ -88,10 +88,10 @@ 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.7' +gem 'strong_migrations', '~> 0.8' gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' -gem 'tzinfo-data', '~> 1.2022' +gem 'tzinfo-data', '~> 1.2023' gem 'webpacker', '~> 5.4' gem 'webpush', github: 'ClearlyClaire/webpush', ref: 'f14a4d52e201128b1b00245d11b6de80d6cfdcd9' gem 'webauthn', '~> 3.0' @@ -100,54 +100,92 @@ gem 'json-ld' gem 'json-ld-preloaded', '~> 3.2' gem 'rdf-normalize', '~> 0.5' -group :development, :test do - gem 'fabrication', '~> 2.30' - gem 'fuubar', '~> 2.5' - gem 'i18n-tasks', '~> 1.0', require: false - gem 'pry-byebug', '~> 3.10' - gem 'pry-rails', '~> 0.3' - gem 'rspec-rails', '~> 5.1' - gem 'rubocop-performance', require: false - gem 'rubocop-rails', require: false - gem 'rubocop-rspec', require: false - gem 'rubocop', require: false -end - -group :production, :test do - gem 'private_address_check', '~> 0.5' -end +gem 'private_address_check', '~> 0.5' group :test do - gem 'capybara', '~> 3.38' + # 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' + + # Extra RSpec extenion methods and helpers for sidekiq + 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 'faker', '~> 3.1' - gem 'json-schema', '~> 3.0' - gem 'rack-test', '~> 2.0' + + # Generating fake data for specs + gem 'faker', '~> 3.2' + + # Generate test objects for specs + gem 'fabrication', '~> 2.30' + + # Add back helpers functions removed in Rails 5.1 gem 'rails-controller-testing', '~> 1.0' - gem 'rspec_junit_formatter', '~> 0.6' - gem 'rspec-sidekiq', '~> 3.1' + + # Validate schemas in specs + gem 'json-schema', '~> 4.0' + + # Test harness fo rack components + gem 'rack-test', '~> 2.1' + + # Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false gem 'simplecov', '~> 0.22', require: false + + # Stub web requests for specs gem 'webmock', '~> 3.18' end group :development do - gem 'active_record_query_trace', '~> 1.8' + # Code linting CLI and plugins + gem 'rubocop', require: false + gem 'rubocop-capybara', require: false + gem 'rubocop-performance', require: false + gem 'rubocop-rails', require: false + gem 'rubocop-rspec', require: false + + # Annotates modules with schema gem 'annotate', '~> 3.2' + + # Enhanced error message pages for development gem 'better_errors', '~> 2.9' gem 'binding_of_caller', '~> 1.0' - gem 'bullet', '~> 7.0' + + # Preview mail in the browser gem 'letter_opener', '~> 1.8' gem 'letter_opener_web', '~> 2.0' - gem 'memory_profiler' - gem 'brakeman', '~> 5.4', require: false + + # Security analysis CLI tools + gem 'brakeman', '~> 6.0', require: false gem 'bundler-audit', '~> 0.9', require: false - gem 'capistrano', '~> 3.17' - gem 'capistrano-rails', '~> 1.6' - gem 'capistrano-rbenv', '~> 2.2' - gem 'capistrano-yarn', '~> 2.0' + # Linter CLI for HAML files + gem 'haml_lint', require: false - gem 'stackprof' + # Validate missing i18n keys + gem 'i18n-tasks', '~> 1.0', require: false +end + +group :development, :test do + # 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 @@ -160,3 +198,6 @@ gem 'xorcist', '~> 1.1' gem 'cocoon', '~> 1.2' gem 'net-http', '~> 0.3.2' +gem 'rubyzip', '~> 2.3' + +gem 'hcaptcha', '~> 7.1' diff --git a/Gemfile.lock b/Gemfile.lock index 6e4c4cdc3ef..33e355a06b4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,16 +8,15 @@ GIT jwt (~> 2.0) GIT - remote: https://github.com/kreeti/kt-paperclip.git - revision: 11abf222dc31bff71160a1d138b445214f434b2b - ref: 11abf222dc31bff71160a1d138b445214f434b2b + remote: https://github.com/jhawthorn/nsa.git + revision: e020fcc3a54d993ab45b7194d89ab720296c111b + ref: e020fcc3a54d993ab45b7194d89ab720296c111b specs: - kt-paperclip (7.1.1) - activemodel (>= 4.2.0) - activesupport (>= 4.2.0) - marcel (~> 1.0.1) - mime-types - terrapin (~> 0.6.0) + nsa (0.2.8) + activesupport (>= 4.2, < 7.2) + concurrent-ruby (~> 1.0, >= 1.0.2) + sidekiq (>= 3.5) + statsd-ruby (~> 1.4, >= 1.4.0) GIT remote: https://github.com/mastodon/rails-settings-cached.git @@ -27,151 +26,163 @@ GIT rails-settings-cached (0.6.6) rails (>= 4.2.0) +GIT + remote: https://github.com/stanhu/omniauth-cas.git + revision: 4211e6d05941b4a981f9a36b49ec166cecd0e271 + ref: 4211e6d05941b4a981f9a36b49ec166cecd0e271 + specs: + omniauth-cas (2.0.0) + addressable (~> 2.3) + nokogiri (~> 1.5) + omniauth (>= 1.2, < 3) + GEM remote: https://rubygems.org/ specs: - actioncable (6.1.7.2) - actionpack (= 6.1.7.2) - activesupport (= 6.1.7.2) + actioncable (7.1.1) + actionpack (= 7.1.1) + activesupport (= 7.1.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.2) - actionpack (= 6.1.7.2) - activejob (= 6.1.7.2) - activerecord (= 6.1.7.2) - activestorage (= 6.1.7.2) - activesupport (= 6.1.7.2) + zeitwerk (~> 2.6) + actionmailbox (7.1.1) + actionpack (= 7.1.1) + activejob (= 7.1.1) + activerecord (= 7.1.1) + activestorage (= 7.1.1) + activesupport (= 7.1.1) mail (>= 2.7.1) - actionmailer (6.1.7.2) - actionpack (= 6.1.7.2) - actionview (= 6.1.7.2) - activejob (= 6.1.7.2) - activesupport (= 6.1.7.2) + net-imap + net-pop + net-smtp + actionmailer (7.1.1) + actionpack (= 7.1.1) + actionview (= 7.1.1) + activejob (= 7.1.1) + activesupport (= 7.1.1) mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (6.1.7.2) - actionview (= 6.1.7.2) - activesupport (= 6.1.7.2) - 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.2) - actionpack (= 6.1.7.2) - activerecord (= 6.1.7.2) - activestorage (= 6.1.7.2) - activesupport (= 6.1.7.2) + net-imap + net-pop + net-smtp + rails-dom-testing (~> 2.2) + actionpack (7.1.1) + actionview (= 7.1.1) + activesupport (= 7.1.1) nokogiri (>= 1.8.5) - actionview (6.1.7.2) - activesupport (= 6.1.7.2) + 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.1) + actionpack (= 7.1.1) + activerecord (= 7.1.1) + activestorage (= 7.1.1) + activesupport (= 7.1.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (7.1.1) + activesupport (= 7.1.1) 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) - active_record_query_trace (1.8) - activejob (6.1.7.2) - activesupport (= 6.1.7.2) + activejob (7.1.1) + activesupport (= 7.1.1) globalid (>= 0.3.6) - activemodel (6.1.7.2) - activesupport (= 6.1.7.2) - activerecord (6.1.7.2) - activemodel (= 6.1.7.2) - activesupport (= 6.1.7.2) - activestorage (6.1.7.2) - actionpack (= 6.1.7.2) - activejob (= 6.1.7.2) - activerecord (= 6.1.7.2) - activesupport (= 6.1.7.2) + activemodel (7.1.1) + activesupport (= 7.1.1) + activerecord (7.1.1) + activemodel (= 7.1.1) + activesupport (= 7.1.1) + timeout (>= 0.4.0) + activestorage (7.1.1) + actionpack (= 7.1.1) + activejob (= 7.1.1) + activerecord (= 7.1.1) + activesupport (= 7.1.1) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (6.1.7.2) + activesupport (7.1.1) + 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.1) + addressable (2.8.5) 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) rake (>= 10.4, < 14.0) ast (2.4.2) - attr_encrypted (3.1.0) + attr_encrypted (4.0.0) encryptor (~> 3.0.0) attr_required (1.0.1) awrence (1.2.1) aws-eventstream (1.2.0) - aws-partitions (1.711.0) - aws-sdk-core (3.170.0) + aws-partitions (1.809.0) + aws-sdk-core (3.181.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.62.0) - aws-sdk-core (~> 3, >= 3.165.0) + aws-sdk-kms (1.71.0) + aws-sdk-core (~> 3, >= 3.177.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.119.1) - aws-sdk-core (~> 3, >= 3.165.0) + aws-sdk-s3 (1.133.0) + aws-sdk-core (~> 3, >= 3.181.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.5.2) + aws-sigv4 (~> 1.6) + aws-sigv4 (1.6.0) aws-eventstream (~> 1, >= 1.0.2) - bcrypt (3.1.17) - better_errors (2.9.1) - coderay (>= 1.0.0) + 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.1.1) + bcrypt (3.1.19) + better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) - better_html (2.0.1) + rouge (>= 1.0.0) + 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.4) bindata (2.4.15) binding_of_caller (1.0.0) debug_inspector (>= 0.0.1) blurhash (0.1.7) bootsnap (1.16.0) msgpack (~> 1.2) - brakeman (5.4.0) - browser (4.2.0) + brakeman (6.0.1) + browser (5.3.1) brpoplpush-redis_script (0.1.3) concurrent-ruby (~> 1.0, >= 1.0.5) redis (>= 1.0, < 6) builder (3.2.4) - bullet (7.0.7) - activesupport (>= 3.0.0) - uniform_notifier (~> 1.11) bundler-audit (0.9.1) bundler (>= 1.2.0, < 3) thor (~> 1.0) - byebug (11.1.3) - capistrano (3.17.1) - airbrussh (>= 1.0.0) - i18n - rake (>= 10.0.0) - sshkit (>= 1.9.0) - capistrano-bundler (2.0.1) - 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.38.0) + capybara (3.39.2) addressable matrix mini_mime (>= 0.1.3) @@ -184,38 +195,41 @@ GEM activesupport cbor (0.5.9.6) charlock_holmes (0.7.7) - chewy (7.2.7) + chewy (7.3.4) activesupport (>= 5.2) elasticsearch (>= 7.12.0, < 7.14.0) elasticsearch-dsl chunky_png (1.4.0) climate_control (0.2.0) cocoon (1.2.15) - coderay (1.1.3) color_diff (0.1) - concurrent-ruby (1.2.0) - connection_pool (2.3.0) + concurrent-ruby (1.2.2) + connection_pool (2.4.1) cose (1.3.0) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) crack (0.4.5) rexml crass (1.0.6) - css_parser (1.12.0) + css_parser (1.14.0) addressable + 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.3) - debug_inspector (1.0.0) - devise (4.8.1) + debug_inspector (1.1.0) + devise (4.9.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (4.0.2) - activesupport (< 7.1) - attr_encrypted (>= 1.3, < 4, != 2) + 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) @@ -226,12 +240,14 @@ GEM docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.6.4) + doorkeeper (5.6.6) railties (>= 5) dotenv (2.8.1) dotenv-rails (2.8.1) dotenv (= 2.8.1) railties (>= 3.2) + drb (2.1.1) + ruby2_keywords ed25519 (1.3.0) elasticsearch (7.13.3) elasticsearch-api (= 7.13.3) @@ -246,9 +262,9 @@ GEM erubi (1.12.0) et-orbi (1.2.7) tzinfo - excon (0.95.0) + excon (0.100.0) fabrication (2.30.0) - faker (3.1.1) + faker (3.2.1) i18n (>= 1.8.11, < 2) faraday (1.10.3) faraday-em_http (~> 1.0) @@ -273,8 +289,10 @@ 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.6) + fastimage (2.2.7) ffi (1.15.5) ffi-compiler (1.0.1) ffi (>= 1.0.0) @@ -298,9 +316,9 @@ GEM 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.2.0) temple (>= 0.8.2) thor tilt @@ -309,9 +327,17 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) + haml_lint (0.51.0) + haml (>= 4.0) + parallel (~> 1.10) + rainbow + rubocop (>= 1.0) + sysexits (~> 1.1) hashdiff (1.0.1) hashie (5.0.0) - highline (2.0.3) + hcaptcha (7.1.0) + json + highline (2.1.0) hiredis (0.6.3) hkdf (0.3.0) htmlentities (4.3.4) @@ -328,43 +354,47 @@ GEM httplog (1.6.2) rack (>= 2.0) rainbow (>= 2.0.0) - i18n (1.12.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) + io-console (0.6.0) ipaddress (0.8.3) + irb (1.8.1) + rdoc + reline (>= 0.3.8) jmespath (1.6.2) json (2.6.3) - json-canonicalization (0.3.0) + json-canonicalization (0.3.2) json-jwt (1.15.3) activesupport (>= 4.2) aes_key_wrap bindata httpclient - json-ld (3.2.3) + json-ld (3.2.5) htmlentities (~> 4.3) - json-canonicalization (~> 0.3) + json-canonicalization (~> 0.3, >= 0.3.2) link_header (~> 0.0, >= 0.0.8) multi_json (~> 1.15) - rack (~> 2.2) - rdf (~> 3.2, >= 3.2.9) + 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 (3.0.0) + json-schema (4.0.0) addressable (>= 2.8) jsonapi-renderer (0.2.2) - jwt (2.7.0) + jwt (2.7.1) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -377,8 +407,15 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - launchy (2.5.0) - addressable (~> 2.7) + kt-paperclip (7.2.1) + activemodel (>= 4.2.0) + activesupport (>= 4.2.0) + marcel (~> 1.0.1) + mime-types + terrapin (~> 0.6.0) + language_server-protocol (3.17.0.3) + launchy (2.5.2) + addressable (~> 2.8) letter_opener (1.8.1) launchy (>= 2.2, < 3) letter_opener_web (2.0.0) @@ -390,75 +427,68 @@ 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.19.1) + loofah (2.21.4) crass (~> 1.0.2) - nokogiri (>= 1.5.9) - mail (2.8.0.1) + nokogiri (>= 1.12.0) + mail (2.8.1) mini_mime (>= 0.1.1) net-imap net-pop net-smtp - makara (0.5.1) - activerecord (>= 5.2.0) marcel (1.0.2) mario-redis-lock (1.2.1) redis (>= 3.0.5) matrix (0.4.2) + md-paperclip-azure (2.2.0) + addressable (~> 2.5) + azure-storage-blob (~> 2.0.1) + hashie (~> 5.0) memory_profiler (1.0.1) - method_source (1.0.0) - mime-types (3.4.1) + mime-types (3.5.1) mime-types-data (~> 3.2015) - mime-types-data (3.2022.0105) - mini_mime (1.1.2) - mini_portile2 (2.8.1) - minitest (5.17.0) - msgpack (1.6.0) + mime-types-data (3.2023.0808) + mini_mime (1.1.5) + mini_portile2 (2.8.4) + minitest (5.20.0) + msgpack (1.7.1) multi_json (1.15.0) multipart-post (2.3.0) + mutex_m (0.1.2) net-http (0.3.2) uri - net-imap (0.3.4) + net-http-persistent (4.0.2) + connection_pool (~> 2.2) + net-imap (0.4.1) date net-protocol - net-ldap (0.17.1) + net-ldap (0.18.0) net-pop (0.1.2) net-protocol net-protocol (0.2.1) timeout - net-scp (4.0.0.rc1) - net-ssh (>= 2.6.5, < 8.0.0) - net-smtp (0.3.3) + net-smtp (0.4.0) net-protocol - net-ssh (7.0.1) - nio4r (2.5.8) - nokogiri (1.14.2) - mini_portile2 (~> 2.8.0) + nio4r (2.5.9) + nokogiri (1.15.4) + mini_portile2 (~> 2.8.2) racc (~> 1.4) - nsa (0.2.8) - activesupport (>= 4.2, < 7) - concurrent-ruby (~> 1.0, >= 1.0.2) - sidekiq (>= 3.5) - statsd-ruby (~> 1.4, >= 1.4.0) - oj (3.14.2) - omniauth (1.9.2) + oj (3.16.1) + omniauth (2.1.1) hashie (>= 3.4.6) - rack (>= 1.6.2, < 3) - omniauth-cas (2.0.0) - addressable (~> 2.3) - nokogiri (~> 1.5) - omniauth (~> 1.2) - omniauth-rails_csrf_protection (0.1.2) + rack (>= 2.2.3) + rack-protection + omniauth-rails_csrf_protection (1.0.1) actionpack (>= 4.2) - omniauth (>= 1.3.1) - omniauth-saml (1.10.3) - omniauth (~> 1.3, >= 1.3.2) - ruby-saml (~> 1.9) - omniauth_openid_connect (0.6.0) + 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) openid_connect (1.4.2) @@ -476,19 +506,19 @@ GEM openssl-signature_algorithm (1.3.0) openssl (> 2.0) orm_adapter (0.5.0) - ox (2.14.14) - parallel (1.22.1) - parser (3.2.1.0) + ox (2.14.17) + parallel (1.23.0) + parser (3.2.2.4) ast (~> 2.4.1) + racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.4.5) - pghero (3.1.0) + pg (1.5.4) + pghero (3.3.4) activerecord (>= 6) - pkg-config (1.5.1) posix-spawn (0.3.15) - premailer (1.18.0) + premailer (1.21.0) addressable css_parser (>= 1.12.0) htmlentities (>= 4.0.0) @@ -497,25 +527,19 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) private_address_check (0.5.0) - pry (0.14.1) - coderay (~> 1.1) - method_source (~> 1.0) - pry-byebug (3.10.1) - byebug (~> 11.0) - pry (>= 0.13, < 0.15) - pry-rails (0.3.9) - pry (>= 0.10.4) - public_suffix (5.0.1) - puma (5.6.5) + psych (5.1.1) + stringio + public_suffix (5.0.3) + puma (6.4.0) nio4r (~> 2.0) pundit (2.3.0) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.6.2) - rack (2.2.6.2) - rack-attack (6.6.1) - rack (>= 1.0, < 3) - rack-cors (1.1.1) + racc (1.7.1) + 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) activesupport @@ -523,139 +547,168 @@ GEM httpclient json-jwt (>= 1.11.0) rack (>= 2.1.0) + rack-protection (3.0.5) + rack rack-proxy (0.7.6) rack - rack-test (2.0.2) + rack-session (1.0.1) + rack (< 3) + rack-test (2.1.0) rack (>= 1.3) - rails (6.1.7.2) - actioncable (= 6.1.7.2) - actionmailbox (= 6.1.7.2) - actionmailer (= 6.1.7.2) - actionpack (= 6.1.7.2) - actiontext (= 6.1.7.2) - actionview (= 6.1.7.2) - activejob (= 6.1.7.2) - activemodel (= 6.1.7.2) - activerecord (= 6.1.7.2) - activestorage (= 6.1.7.2) - activesupport (= 6.1.7.2) + rackup (1.0.0) + rack (< 3) + webrick + rails (7.1.1) + actioncable (= 7.1.1) + actionmailbox (= 7.1.1) + actionmailer (= 7.1.1) + actionpack (= 7.1.1) + actiontext (= 7.1.1) + actionview (= 7.1.1) + activejob (= 7.1.1) + activemodel (= 7.1.1) + activerecord (= 7.1.1) + activestorage (= 7.1.1) + activesupport (= 7.1.1) bundler (>= 1.15.0) - railties (= 6.1.7.2) - sprockets-rails (>= 2.0.0) + railties (= 7.1.1) 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.5.0) - loofah (~> 2.19, >= 2.19.1) - rails-i18n (6.0.0) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + rails-i18n (7.0.8) i18n (>= 0.7, < 2) - railties (>= 6.0.0, < 7) - railties (6.1.7.2) - actionpack (= 6.1.7.2) - activesupport (= 6.1.7.2) - method_source + railties (>= 6.0.0, < 8) + railties (7.1.1) + actionpack (= 7.1.1) + activesupport (= 7.1.1) + 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.9) + rdf (3.2.11) link_header (~> 0.0, >= 0.0.8) - rdf-normalize (0.5.1) + rdf-normalize (0.6.1) rdf (~> 3.2) + rdoc (6.5.0) + psych (>= 4.0.0) redcarpet (3.6.0) - redis (4.5.1) - redis-namespace (1.10.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.7.0) + regexp_parser (2.8.2) + reline (0.3.9) + io-console (~> 0.5) request_store (1.5.1) rack (>= 1.4) - responders (3.0.1) - actionpack (>= 5.0) - railties (>= 5.0) - rexml (3.2.5) - rotp (6.2.0) + responders (3.1.1) + actionpack (>= 5.2) + railties (>= 5.2) + rexml (3.2.6) + rotp (6.3.0) + rouge (4.1.2) rpam2 (4.0.2) - rqrcode (2.1.2) + rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rspec-core (3.11.0) - rspec-support (~> 3.11.0) - rspec-expectations (3.11.0) + rspec-core (3.12.2) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.3) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.11.0) - rspec-mocks (3.11.1) + rspec-support (~> 3.12.0) + rspec-github (2.4.0) + rspec-core (~> 3.0) + rspec-mocks (3.12.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.11.0) - rspec-rails (5.1.2) - actionpack (>= 5.2) - activesupport (>= 5.2) - railties (>= 5.2) - rspec-core (~> 3.10) - rspec-expectations (~> 3.10) - rspec-mocks (~> 3.10) - rspec-support (~> 3.10) - rspec-sidekiq (3.1.0) - rspec-core (~> 3.0, >= 3.0.0) - sidekiq (>= 2.4.0) - rspec-support (3.11.1) - rspec_junit_formatter (0.6.0) - rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.45.1) + rspec-support (~> 3.12.0) + rspec-rails (6.0.3) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) + rspec-sidekiq (4.0.1) + rspec-core (~> 3.0) + rspec-expectations (~> 3.0) + rspec-mocks (~> 3.0) + sidekiq (>= 5, < 8) + rspec-support (3.12.1) + rspec_chunked (0.6) + rubocop (1.57.1) + base64 (~> 0.1.1) json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.0.0) + parser (>= 3.2.2.4) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.24.1, < 2.0) + rubocop-ast (>= 1.28.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.24.1) - parser (>= 3.1.1.0) - rubocop-capybara (2.17.0) + rubocop-ast (1.29.0) + parser (>= 3.2.1.0) + rubocop-capybara (2.19.0) rubocop (~> 1.41) - rubocop-performance (1.16.0) + rubocop-factory_bot (2.23.1) + rubocop (~> 1.33) + rubocop-performance (1.19.1) rubocop (>= 1.7.0, < 2.0) rubocop-ast (>= 0.4.0) - rubocop-rails (2.17.4) + rubocop-rails (2.20.2) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) - rubocop-rspec (2.18.1) + rubocop-rspec (2.23.2) rubocop (~> 1.33) rubocop-capybara (~> 2.17) - ruby-progressbar (1.11.0) - ruby-saml (1.13.0) - nokogiri (>= 1.10.5) + rubocop-factory_bot (~> 2.22) + ruby-prof (1.6.3) + ruby-progressbar (1.13.0) + ruby-saml (1.15.0) + nokogiri (>= 1.13.10) rexml ruby2_keywords (0.0.5) - rufus-scheduler (3.8.2) + rubyzip (2.3.2) + rufus-scheduler (3.9.1) fugit (~> 1.1, >= 1.1.6) safety_net_attestation (0.4.0) jwt (~> 2.0) - sanitize (6.0.1) + sanitize (6.0.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) scenic (1.7.0) activerecord (>= 4.0.0) railties (>= 4.0.0) + selenium-webdriver (4.13.1) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) semantic_range (3.0.0) - sidekiq (6.5.8) + sidekiq (6.5.12) connection_pool (>= 2.2.5, < 3) rack (~> 2.0) redis (>= 4.5.0, < 5) sidekiq-bulk (0.2.0) sidekiq - sidekiq-scheduler (5.0.1) + sidekiq-scheduler (5.0.3) rufus-scheduler (~> 3.2) - sidekiq (>= 4, < 8) + sidekiq (>= 6, < 8) tilt (>= 1.4.0) sidekiq-unique-jobs (7.1.29) brpoplpush-redis_script (> 0.1.1, <= 2.0.0) @@ -682,27 +735,27 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - sshkit (1.21.2) - net-scp (>= 1.1.2) - net-ssh (>= 2.8.0) - stackprof (0.2.23) + stackprof (0.2.25) statsd-ruby (1.5.0) - stoplight (3.0.1) + stoplight (3.0.2) redlock (~> 1.0) - strong_migrations (0.7.9) - activerecord (>= 5) + stringio (3.0.8) + strong_migrations (0.8.0) + activerecord (>= 5.2) swd (1.3.0) activesupport (>= 3) attr_required (>= 0.0.5) httpclient (>= 2.4) - temple (0.10.0) + sysexits (1.2.0) + temple (0.10.2) 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.1) - tilt (2.0.11) - timeout (0.3.1) + test-prof (1.2.3) + thor (1.2.2) + tilt (2.3.0) + timeout (0.4.0) tpm-key_attestation (0.12.0) bindata (~> 2.4) openssl (> 2.0) @@ -722,14 +775,13 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2022.7) + tzinfo-data (1.2023.3) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext unf_ext (0.0.8.2) - unicode-display_width (2.4.2) - uniform_notifier (1.16.0) - uri (0.12.0) + unicode-display_width (2.5.0) + uri (0.12.2) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -750,7 +802,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) @@ -759,59 +811,58 @@ 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.7) + zeitwerk (2.6.12) PLATFORMS ruby DEPENDENCIES active_model_serializers (~> 0.10) - active_record_query_trace (~> 1.8) addressable (~> 2.8) annotate (~> 3.2) - aws-sdk-s3 (~> 1.119) + aws-sdk-s3 (~> 1.123) better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) bootsnap (~> 1.16.0) - brakeman (~> 5.4) + brakeman (~> 6.0) browser - bullet (~> 7.0) bundler-audit (~> 0.9) - capistrano (~> 3.17) - capistrano-rails (~> 1.6) - capistrano-rbenv (~> 2.2) - capistrano-yarn (~> 2.0) - capybara (~> 3.38) + capybara (~> 3.39) charlock_holmes (~> 0.7.7) - chewy (~> 7.2) + chewy (~> 7.3) climate_control (~> 0.2) cocoon (~> 1.2) color_diff (~> 0.1) concurrent-ruby connection_pool - devise (~> 4.8) - devise-two-factor (~> 4.0) + database_cleaner-active_record + devise (~> 4.9) + devise-two-factor (~> 4.1) devise_pam_authenticatable2 (~> 9.2) discard (~> 1.2) doorkeeper (~> 5.6) dotenv-rails (~> 2.8) ed25519 (~> 1.3) fabrication (~> 2.30) - faker (~> 3.1) + faker (~> 3.2) fast_blank (~> 1.0) fastimage fog-core (<= 2.4.0) fog-openstack (~> 0.3) fuubar (~> 2.5) haml-rails (~> 2.0) + haml_lint + hcaptcha (~> 7.1) hiredis (~> 0.6) htmlentities (~> 4.3) http (~> 5.1) @@ -821,64 +872,65 @@ DEPENDENCIES idn-ruby json-ld json-ld-preloaded (~> 3.2) - json-schema (~> 3.0) + json-schema (~> 4.0) kaminari (~> 1.2) - kt-paperclip (~> 7.1)! + kt-paperclip (~> 7.2) letter_opener (~> 1.8) 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) + mime-types (~> 3.5.0) net-http (~> 0.3.2) - net-ldap (~> 0.17) - nokogiri (~> 1.14) - nsa (~> 0.2) + 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_openid_connect (~> 0.6.0) + omniauth (~> 2.0) + omniauth-cas! + omniauth-rails_csrf_protection (~> 1.0) + omniauth-saml (~> 2.0) + omniauth_openid_connect (~> 0.6.1) ox (~> 2.14) parslet - pg (~> 1.4) + pg (~> 1.5) pghero - pkg-config (~> 1.5) posix-spawn premailer-rails private_address_check (~> 0.5) - pry-byebug (~> 3.10) - pry-rails (~> 0.3) public_suffix (~> 5.0) - puma (~> 5.6) + puma (~> 6.3) pundit (~> 2.3) - rack (~> 2.2.6) + rack (~> 2.2.7) rack-attack (~> 6.6) - rack-cors (~> 1.1) - rack-test (~> 2.0) - rails (~> 6.1.7) + rack-cors (~> 2.0) + rack-test (~> 2.1) + rails (~> 7.1.1) rails-controller-testing (~> 1.0) - rails-i18n (~> 6.0) + rails-i18n (~> 7.0) rails-settings-cached (~> 0.6)! rdf-normalize (~> 0.5) redcarpet (~> 3.6) redis (~> 4.5) redis-namespace (~> 1.10) - rexml (~> 3.2) - rqrcode (~> 2.1) - rspec-rails (~> 5.1) - rspec-sidekiq (~> 3.1) - rspec_junit_formatter (~> 0.6) + rqrcode (~> 2.2) + rspec-github (~> 2.4) + rspec-rails (~> 6.0) + rspec-sidekiq (~> 4.0) + rspec_chunked (~> 0.6) rubocop + rubocop-capybara rubocop-performance rubocop-rails rubocop-rspec - ruby-progressbar (~> 1.11) + 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) @@ -890,13 +942,20 @@ DEPENDENCIES sprockets-rails (~> 3.4) stackprof stoplight (~> 3.0.1) - strong_migrations (~> 0.7) + strong_migrations (~> 0.8) + test-prof thor (~> 1.2) tty-prompt (~> 0.23) twitter-text (~> 3.1.0) - tzinfo-data (~> 1.2022) + tzinfo-data (~> 1.2023) webauthn (~> 3.0) webmock (~> 3.18) webpacker (~> 5.4) webpush! xorcist (~> 1.1) + +RUBY VERSION + ruby 3.2.2p53 + +BUNDLED WITH + 2.4.20 diff --git a/Procfile.dev b/Procfile.dev index ba04fb661b3..fbb2c2de23c 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 +webpack: bin/webpack-dev-server diff --git a/README.md b/README.md index 1b5db92a845..ce9b5cfbdc1 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,13 @@ [![GitHub release](https://img.shields.io/github/release/mastodon/mastodon.svg)][releases] -[![Build Status](https://img.shields.io/circleci/project/github/mastodon/mastodon.svg)][circleci] -[![Code Climate](https://img.shields.io/codeclimate/maintainability/mastodon/mastodon.svg)][code_climate] +[![Ruby Testing](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin] -[![Docker Pulls](https://img.shields.io/docker/pulls/tootsuite/mastodon.svg)][docker] [releases]: https://github.com/mastodon/mastodon/releases -[circleci]: https://circleci.com/gh/mastodon/mastodon -[code_climate]: https://codeclimate.com/github/mastodon/mastodon [crowdin]: https://crowdin.com/project/mastodon -[docker]: https://hub.docker.com/r/tootsuite/mastodon/ -Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!) +Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!) Click below to **learn more** in a video: @@ -31,6 +26,8 @@ Click below to **learn more** in a video: - [View sponsors](https://joinmastodon.org/sponsors) - [Blog](https://blog.joinmastodon.org) - [Documentation](https://docs.joinmastodon.org) +- [Roadmap](https://joinmastodon.org/roadmap) +- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon) - [Browse Mastodon servers](https://joinmastodon.org/communities) - [Browse Mastodon apps](https://joinmastodon.org/apps) @@ -54,7 +51,7 @@ Upload and view images and WebM/MP4 videos attached to the updates. Videos with ### Safety and moderation tools -Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/) +Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/) ### OAuth2 and a straightforward REST API @@ -62,22 +59,26 @@ Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Stre ## Deployment -### Tech stack: +### Tech stack - **Ruby on Rails** powers the REST API and other web pages - **React.js** and Redux are used for the dynamic parts of the interface - **Node.js** powers the streaming API -### Requirements: +### Requirements - **PostgreSQL** 9.5+ - **Redis** 4+ - **Ruby** 2.7+ -- **Node.js** 14+ +- **Node.js** 16+ The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, **Scalingo**, and **Nanobox**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation. -A **Vagrant** configuration is included for development purposes. To use it, complete following steps: +## Development + +### Vagrant + +A **Vagrant** configuration is included for development purposes. To use it, complete the following steps: - Install Vagrant and Virtualbox - Install the `vagrant-hostsupdater` plugin: `vagrant plugin install vagrant-hostsupdater` @@ -85,6 +86,42 @@ A **Vagrant** configuration is included for development purposes. To use it, com - Run `vagrant ssh -c "cd /vagrant && foreman start"` - Open `http://mastodon.local` in your browser +### MacOS + +To set up **MacOS** for native development, complete the following steps: + +- Install the latest stable Ruby version (use a Ruby version manager for easy installation and management of Ruby versions) +- Run `brew install postgresql@14` +- Run `brew install redis` +- Run `brew install imagemagick` +- Install Foreman or a similar tool (such as [overmind](https://github.com/DarthSim/overmind)) to handle multiple process launching. +- Navigate to Mastodon's root directory and run `brew install nvm` then `nvm use` to use the version from .nvmrc +- Run `corepack enable && yarn set version classic` +- Run `bundle exec rails db:setup` (optionally prepend `RAILS_ENV=development` to target the dev environment) +- Finally, run `overmind start -f Procfile.dev` + +### Docker + +For development with **Docker**, complete the following steps: + +- Install Docker Desktop +- Run `docker compose -f .devcontainer/docker-compose.yml up -d` +- Run `docker compose -f .devcontainer/docker-compose.yml exec app .devcontainer/post-create.sh` +- Finally, run `docker compose -f .devcontainer/docker-compose.yml exec app foreman start -f Procfile.dev` + +If you are using an IDE with [support for the Development Container specification](https://containers.dev/supporting), it will run the above `docker compose` commands automatically. For **Visual Studio Code** this requires the [Dev Container extension](https://containers.dev/supporting#dev-containers). + +### GitHub Codespaces + +To get you coding in just a few minutes, GitHub Codespaces provides a web-based version of Visual Studio Code and a cloud-hosted development environment fully configured with the software needed for this project.. + +- Click this button to create a new codespace:
+ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=52281283&devcontainer_path=.devcontainer%2Fcodespaces%2Fdevcontainer.json) +- Wait for the environment to build. This will take a few minutes. +- When the editor is ready, run `foreman start -f Procfile.dev` in the terminal. +- After a few seconds, a popup will appear with a button labeled _Open in Browser_. This will open Mastodon. +- On the _Ports_ tab, right click on the “stream” row and select _Port visibility_ → _Public_. + ## Contributing Mastodon is **free, open-source software** licensed under **AGPLv3**. @@ -95,7 +132,7 @@ You can open issues for bugs you've found or features you think are missing. You ## License -Copyright (C) 2016-2022 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md)) +Copyright (C) 2016-2023 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md)) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. diff --git a/Rakefile b/Rakefile index ba6b733dd23..e51cf0e17e8 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 6a51c126abe..3e13377db63 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,11 @@ # Security Policy -If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can reach us at . +If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can either: -You should _not_ report such issues on GitHub or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk. +- open a [Github security issue on the Mastodon project](https://github.com/mastodon/mastodon/security/advisories/new) +- reach us at + +You should _not_ report such issues on public GitHub issues or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk. ## Scope @@ -10,9 +13,10 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through ## Supported Versions -| Version | Supported | -| ------- | --------- | -| 4.1.x | Yes | -| 4.0.x | Yes | -| 3.5.x | Yes | -| < 3.5 | No | +| Version | Supported | +| ------- | ---------------- | +| 4.2.x | Yes | +| 4.1.x | Yes | +| 4.0.x | Until 2023-10-31 | +| 3.5.x | Until 2023-12-31 | +| < 3.5 | No | diff --git a/Vagrantfile b/Vagrantfile index 880cc18495d..4303f8e067c 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -60,6 +60,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 395797b2721..00000000000 --- 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 8810526cb13..00000000000 --- 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 0d29903e607..00000000000 --- 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 new file mode 100644 index 00000000000..d770a78e97c --- /dev/null +++ b/spec/generators/post_deployment_migration_generator_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' +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::Behavior + include Rails::Generators::Testing::Assertions + include FileUtils + + tests described_class + destination File.expand_path('../../tmp', __dir__) + before { prepare_destination } + after { rm_rf(destination_root) } + + describe 'the migration' do + it 'generates expected file' do + run_generator %w(Changes) + + assert_migration('db/post_migrate/changes.rb', /disable_ddl/) + assert_migration('db/post_migrate/changes.rb', /change/) + end + end +end diff --git a/spec/helpers/accounts_helper_spec.rb b/spec/helpers/accounts_helper_spec.rb index 1f412a39ff7..2c949cde696 100644 --- a/spec/helpers/accounts_helper_spec.rb +++ b/spec/helpers/accounts_helper_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe AccountsHelper, type: :helper do +RSpec.describe AccountsHelper do def set_not_embedded_view params[:controller] = "not_#{StatusesHelper::EMBEDDED_CONTROLLER}" params[:action] = "not_#{StatusesHelper::EMBEDDED_ACTION}" diff --git a/spec/helpers/admin/account_moderation_notes_helper_spec.rb b/spec/helpers/admin/account_moderation_notes_helper_spec.rb index 622ce880657..6386f07ac92 100644 --- a/spec/helpers/admin/account_moderation_notes_helper_spec.rb +++ b/spec/helpers/admin/account_moderation_notes_helper_spec.rb @@ -2,11 +2,11 @@ require 'rails_helper' -RSpec.describe Admin::AccountModerationNotesHelper, type: :helper do +RSpec.describe Admin::AccountModerationNotesHelper do include AccountsHelper describe '#admin_account_link_to' do - context 'account is nil' do + context 'when Account is nil' do let(:account) { nil } it 'returns nil' do @@ -30,7 +30,7 @@ RSpec.describe Admin::AccountModerationNotesHelper, type: :helper do end describe '#admin_account_inline_link_to' do - context 'account is nil' do + context 'when Account is nil' do let(:account) { nil } it 'returns nil' do @@ -42,13 +42,11 @@ RSpec.describe Admin::AccountModerationNotesHelper, type: :helper do let(:account) { Fabricate(:account) } it 'calls #link_to' do - expect(helper).to receive(:link_to).with( - admin_account_path(account.id), - class: name_tag_classes(account, true), - title: account.acct - ) + result = helper.admin_account_inline_link_to(account) - helper.admin_account_inline_link_to(account) + expect(result).to match(name_tag_classes(account, true)) + expect(result).to match(account.acct) + expect(result).to match(admin_account_path(account.id)) end end end diff --git a/spec/helpers/admin/action_log_helper_spec.rb b/spec/helpers/admin/action_log_helper_spec.rb deleted file mode 100644 index 9d7ed4ab765..00000000000 --- a/spec/helpers/admin/action_log_helper_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Admin::ActionLogsHelper, type: :helper do -end diff --git a/spec/helpers/admin/dashboard_helper_spec.rb b/spec/helpers/admin/dashboard_helper_spec.rb new file mode 100644 index 00000000000..59062e48396 --- /dev/null +++ b/spec/helpers/admin/dashboard_helper_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::DashboardHelper do + describe 'relevant_account_timestamp' do + context 'with an account with older sign in' do + let(:account) { Fabricate(:account) } + let(:stamp) { 10.days.ago } + + it 'returns a time element' do + account.user.update(current_sign_in_at: stamp) + result = helper.relevant_account_timestamp(account) + + expect(result).to match('time-ago') + expect(result).to match(I18n.l(stamp)) + end + end + + context 'with an account with newer sign in' do + let(:account) { Fabricate(:account) } + + it 'returns a time element' do + account.user.update(current_sign_in_at: 10.hours.ago) + result = helper.relevant_account_timestamp(account) + + expect(result).to eq(I18n.t('generic.today')) + end + end + + context 'with an account where the user is pending' do + let(:account) { Fabricate(:account) } + + it 'returns a time element' do + account.user.update(current_sign_in_at: nil) + account.user.update(approved: false) + result = helper.relevant_account_timestamp(account) + + expect(result).to match('time-ago') + expect(result).to match(I18n.l(account.user.created_at)) + end + end + + context 'with an account with a last status value' do + let(:account) { Fabricate(:account) } + let(:stamp) { 5.minutes.ago } + + it 'returns a time element' do + account.user.update(current_sign_in_at: nil) + account.account_stat.update(last_status_at: stamp) + result = helper.relevant_account_timestamp(account) + + expect(result).to match('time-ago') + expect(result).to match(I18n.l(stamp)) + end + end + + context 'with an account without sign in or last status or pending' do + let(:account) { Fabricate(:account) } + + it 'returns a time element' do + account.user.update(current_sign_in_at: nil) + result = helper.relevant_account_timestamp(account) + + expect(result).to eq('-') + end + end + end +end diff --git a/spec/helpers/admin/disputes_helper_spec.rb b/spec/helpers/admin/disputes_helper_spec.rb new file mode 100644 index 00000000000..5f9a85df869 --- /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 9d4ea282946..40ed63239f0 100644 --- a/spec/helpers/admin/filter_helper_spec.rb +++ b/spec/helpers/admin/filter_helper_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe Admin::FilterHelper do @@ -5,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/admin/trends/statuses_helper_spec.rb b/spec/helpers/admin/trends/statuses_helper_spec.rb new file mode 100644 index 00000000000..92caae69099 --- /dev/null +++ b/spec/helpers/admin/trends/statuses_helper_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Trends::StatusesHelper do + describe '.one_line_preview' do + before do + allow(helper).to receive(:current_user).and_return(Fabricate.build(:user)) + end + + context 'with a local status' do + let(:status) { Fabricate.build(:status, text: 'Test local status') } + + it 'renders a correct preview text' do + result = helper.one_line_preview(status) + + expect(result).to eq 'Test local status' + end + end + + context 'with a remote status' do + let(:status) { Fabricate.build(:status, uri: 'https://sfd.sdf', text: '

Test remote status

text

') } + + it 'renders a correct preview text' do + result = helper.one_line_preview(status) + + expect(result).to eq 'Test remote status' + end + end + + context 'with a status that has empty text' do + let(:status) { Fabricate.build(:status, text: '') } + + it 'renders a correct preview text' do + result = helper.one_line_preview(status) + + expect(result).to eq '' + end + end + + context 'with a status that has emoji' do + before { Fabricate(:custom_emoji, shortcode: 'florpy') } + + let(:status) { Fabricate(:status, text: 'hello there :florpy:') } + + it 'renders a correct preview text' do + result = helper.one_line_preview(status) + + expect(result).to match 'hello there' + expect(result).to match ' Hello this is a nice message for you to quote. + > Be careful because it has two lines. + EXPECTED + end + end + + describe 'storage_host' do + context 'when S3 alias is present' do + around do |example| + ClimateControl.modify S3_ALIAS_HOST: 's3.alias' do + example.run + end + end + + it 'returns true' do + expect(helper.storage_host).to eq('https://s3.alias') + end + end + + context 'when S3 alias includes a path component' do + around do |example| + ClimateControl.modify S3_ALIAS_HOST: 's3.alias/path' do + example.run + end + end + + it 'returns a correct URL' do + expect(helper.storage_host).to eq('https://s3.alias/path') + end + end + + context 'when S3 cloudfront is present' do + around do |example| + ClimateControl.modify S3_CLOUDFRONT_HOST: 's3.cloudfront' do + example.run + end + end + + it 'returns true' do + expect(helper.storage_host).to eq('https://s3.cloudfront') + end + end + end + + describe 'storage_host?' do + context 'when S3 alias is present' do + around do |example| + ClimateControl.modify S3_ALIAS_HOST: 's3.alias' do + example.run + end + end + + it 'returns true' do + expect(helper.storage_host?).to be true + end + end + + context 'when S3 cloudfront is present' do + around do |example| + ClimateControl.modify S3_CLOUDFRONT_HOST: 's3.cloudfront' do + example.run + end + end + + it 'returns true' do + expect(helper.storage_host?).to be true + end + end + + context 'when neither env value is present' do + it 'returns false' do + expect(helper.storage_host?).to be false + end + end + end + + describe 'visibility_icon' do + it 'returns a globe icon for a public visible status' do + result = helper.visibility_icon Status.new(visibility: 'public') + expect(result).to match(/globe/) + end + + it 'returns an unlock icon for a unlisted visible status' do + result = helper.visibility_icon Status.new(visibility: 'unlisted') + expect(result).to match(/unlock/) + end + + it 'returns a lock icon for a private visible status' do + result = helper.visibility_icon Status.new(visibility: 'private') + expect(result).to match(/lock/) + end + + it 'returns an at icon for a direct visible status' do + result = helper.visibility_icon Status.new(visibility: 'direct') + expect(result).to match(/at/) + end + end + describe 'title' do around do |example| site_title = Setting.site_title @@ -116,8 +292,9 @@ describe ApplicationHelper do 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 end end diff --git a/spec/helpers/flashes_helper_spec.rb b/spec/helpers/flashes_helper_spec.rb index ea143eed722..035e8a1de07 100644 --- a/spec/helpers/flashes_helper_spec.rb +++ b/spec/helpers/flashes_helper_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -describe FlashesHelper, type: :helper do +describe FlashesHelper do describe 'user_facing_flashes' do it 'returns user facing flashes' do flash[:alert] = 'an alert' diff --git a/spec/helpers/formatting_helper_spec.rb b/spec/helpers/formatting_helper_spec.rb index af604a87b58..d6e7631f66c 100644 --- a/spec/helpers/formatting_helper_spec.rb +++ b/spec/helpers/formatting_helper_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -describe FormattingHelper, type: :helper do +describe FormattingHelper do include Devise::Test::ControllerHelpers describe '#rss_status_content_format' do diff --git a/spec/helpers/home_helper_spec.rb b/spec/helpers/home_helper_spec.rb index a3dc6f836ff..c6baec5a1ff 100644 --- a/spec/helpers/home_helper_spec.rb +++ b/spec/helpers/home_helper_spec.rb @@ -1,9 +1,119 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe HomeHelper, type: :helper do +RSpec.describe HomeHelper do describe 'default_props' do it 'returns default properties according to the context' do expect(helper.default_props).to eq locale: I18n.locale end end + + describe 'account_link_to' do + context 'with a missing account' do + let(:account) { nil } + + it 'returns a button' do + result = helper.account_link_to(account) + + expect(result).to match t('about.contact_missing') + end + end + + 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_messages(current_account: account, prefers_autoplay?: false) + result = helper.account_link_to(account) + + expect(result).to match "@#{account.acct}" + end + end + end + end + + describe 'obscured_counter' do + context 'with a value of less than zero' do + let(:count) { -10 } + + it 'returns the correct string' do + expect(helper.obscured_counter(count)).to eq '0' + end + end + + context 'with a value of zero' do + let(:count) { 0 } + + it 'returns the correct string' do + expect(helper.obscured_counter(count)).to eq '0' + end + end + + context 'with a value of one' do + let(:count) { 1 } + + it 'returns the correct string' do + expect(helper.obscured_counter(count)).to eq '1' + end + end + + context 'with a value of more than one' do + let(:count) { 10 } + + it 'returns the correct string' do + expect(helper.obscured_counter(count)).to eq '1+' + end + end + end + + describe 'custom_field_classes' do + context 'with a verified field' do + let(:field) { instance_double(Account::Field, verified?: true) } + + it 'returns verified string' do + result = helper.custom_field_classes(field) + expect(result).to eq 'verified' + end + end + + context 'with a non-verified field' do + let(:field) { instance_double(Account::Field, verified?: false) } + + it 'returns verified string' do + result = helper.custom_field_classes(field) + expect(result).to eq 'emojify' + end + end + end + + describe 'sign_up_messages' do + context 'with closed registrations' do + it 'returns correct sign up message' do + allow(helper).to receive(:closed_registrations?).and_return(true) + result = helper.sign_up_message + + expect(result).to eq t('auth.registration_closed', instance: 'cb6e6126.ngrok.io') + end + end + + context 'with open registrations' do + it 'returns correct sign up message' do + allow(helper).to receive_messages(closed_registrations?: false, open_registrations?: true) + result = helper.sign_up_message + + expect(result).to eq t('auth.register') + end + end + + context 'with approved registrations' do + it 'returns correct sign up message' do + 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') + end + end + end end diff --git a/spec/helpers/jsonld_helper_spec.rb b/spec/helpers/jsonld_helper_spec.rb index ddd4bfe6293..5124bcf855b 100644 --- a/spec/helpers/jsonld_helper_spec.rb +++ b/spec/helpers/jsonld_helper_spec.rb @@ -22,14 +22,14 @@ describe JsonLdHelper do end describe '#first_of_value' do - context 'value.is_a?(Array)' 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 '!value.is_a?(Array)' do + context 'with !value.is_a?(Array)' do it 'returns value' do value = 'a' expect(helper.first_of_value(value)).to be 'a' @@ -38,14 +38,14 @@ describe JsonLdHelper do end describe '#supported_context?' do - context "!json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::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 'else' do + context 'when not in activitypub tagmanager context' do it 'returns false' do json = nil expect(helper.supported_context?(json)).to be false @@ -90,7 +90,7 @@ describe JsonLdHelper do end end - context 'compaction and forwarding' do + context 'with compaction and forwarding' do let(:json) do { '@context' => [ @@ -158,14 +158,14 @@ describe JsonLdHelper do it 'deems a safe compacting as such' do json['object'].delete('convo') compacted = compact(json) - deemed_compatible = patch_for_forwarding!(json, compacted) + patch_for_forwarding!(json, compacted) expect(compacted['to']).to eq ['https://www.w3.org/ns/activitystreams#Public'] expect(safe_for_forwarding?(json, compacted)).to be true end it 'deems an unsafe compacting as such' do compacted = compact(json) - deemed_compatible = patch_for_forwarding!(json, compacted) + patch_for_forwarding!(json, compacted) expect(compacted['to']).to eq ['https://www.w3.org/ns/activitystreams#Public'] expect(safe_for_forwarding?(json, compacted)).to be false end diff --git a/spec/helpers/languages_helper_spec.rb b/spec/helpers/languages_helper_spec.rb index 217c9b2397d..99461b293ba 100644 --- a/spec/helpers/languages_helper_spec.rb +++ b/spec/helpers/languages_helper_spec.rb @@ -10,14 +10,80 @@ describe LanguagesHelper do end describe 'native_locale_name' do - it 'finds the human readable native name from a key' do - expect(helper.native_locale_name(:de)).to eq('Deutsch') + context 'with a blank locale' do + it 'defaults to a generic value' do + expect(helper.native_locale_name(nil)).to eq(I18n.t('generic.none')) + end + end + + context 'with a locale of `und`' do + it 'defaults to a generic value' do + expect(helper.native_locale_name('und')).to eq(I18n.t('generic.none')) + end + end + + context 'with a supported locale' do + it 'finds the human readable native name from a key' do + expect(helper.native_locale_name(:de)).to eq('Deutsch') + end + end + + context 'with a regional locale' do + it 'finds the human readable regional name from a key' do + expect(helper.native_locale_name('en-GB')).to eq('English (British)') + end + end + + context 'with a non-existent locale' do + it 'returns the supplied locale value' do + expect(helper.native_locale_name(:xxx)).to eq(:xxx) + end end end describe 'standard_locale_name' do - it 'finds the human readable standard name from a key' do - expect(helper.standard_locale_name(:de)).to eq('German') + context 'with a blank locale' do + it 'defaults to a generic value' do + expect(helper.standard_locale_name(nil)).to eq(I18n.t('generic.none')) + end + end + + context 'with a non-existent locale' do + it 'returns the supplied locale value' do + expect(helper.standard_locale_name(:xxx)).to eq(:xxx) + end + end + + context 'with a supported locale' do + it 'finds the human readable standard name from a key' do + expect(helper.standard_locale_name(:de)).to eq('German') + 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 new file mode 100644 index 00000000000..71a9af6f3bc --- /dev/null +++ b/spec/helpers/media_component_helper_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe MediaComponentHelper do + 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 + end + + describe 'render_audio_component' 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 + end + + describe 'render_media_gallery_component' 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(:result) { helper.render_card_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('Card') + end + end + + describe 'render_poll_component' 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 + end + + private + + def parsed_html + Nokogiri::Slop(result) + end +end diff --git a/spec/helpers/react_component_helper_spec.rb b/spec/helpers/react_component_helper_spec.rb new file mode 100644 index 00000000000..28208b619ba --- /dev/null +++ b/spec/helpers/react_component_helper_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ReactComponentHelper do + describe 'react_component' do + context 'with no block passed in' do + let(:result) { helper.react_component('name', { one: :two }) } + + it 'returns a tag with data attributes' do + expect(parsed_html.div['data-component']).to eq('Name') + expect(parsed_html.div['data-props']).to eq('{"one":"two"}') + end + end + + context 'with a block passed in' do + let(:result) do + helper.react_component('name', { one: :two }) do + helper.content_tag(:nav, 'ok') + end + end + + it 'returns a tag with data attributes' do + expect(parsed_html.div['data-component']).to eq('Name') + expect(parsed_html.div['data-props']).to eq('{"one":"two"}') + expect(parsed_html.div.nav.content).to eq('ok') + end + end + end + + describe 'react_admin_component' do + let(:result) { helper.react_admin_component('name', { one: :two }) } + + it 'returns a tag with data attributes' do + expect(parsed_html.div['data-admin-component']).to eq('Name') + expect(parsed_html.div['data-props']).to eq('{"one":"two"}') + end + end + + private + + def parsed_html + Nokogiri::Slop(result) + end +end diff --git a/spec/helpers/routing_helper_spec.rb b/spec/helpers/routing_helper_spec.rb index 940392c9b08..852d02cebc4 100644 --- a/spec/helpers/routing_helper_spec.rb +++ b/spec/helpers/routing_helper_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe RoutingHelper, type: :helper do +RSpec.describe RoutingHelper do describe '.full_asset_url' do around do |example| use_s3 = Rails.configuration.x.use_s3 @@ -24,7 +24,7 @@ RSpec.describe RoutingHelper, type: :helper do end end - context 'Do not use S3' do + context 'when not using S3' do before do Rails.configuration.x.use_s3 = false end @@ -32,7 +32,7 @@ RSpec.describe RoutingHelper, type: :helper do it_behaves_like 'returns full path URL' end - context 'Use S3' do + context 'when using S3' do before do Rails.configuration.x.use_s3 = true end diff --git a/spec/helpers/settings_helper_spec.rb b/spec/helpers/settings_helper_spec.rb new file mode 100644 index 00000000000..cba5c6ee891 --- /dev/null +++ b/spec/helpers/settings_helper_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe SettingsHelper do + describe 'session_device_icon' do + context 'with a mobile device' do + let(:session) { SessionActivation.new(user_agent: 'Mozilla/5.0 (iPhone)') } + + it 'detects the device and returns a descriptive string' do + result = helper.session_device_icon(session) + + expect(result).to eq('mobile') + end + end + + context 'with a tablet device' do + let(:session) { SessionActivation.new(user_agent: 'Mozilla/5.0 (iPad)') } + + it 'detects the device and returns a descriptive string' do + result = helper.session_device_icon(session) + + expect(result).to eq('tablet') + end + end + + context 'with a desktop device' do + let(:session) { SessionActivation.new(user_agent: 'Mozilla/5.0 (Macintosh)') } + + it 'detects the device and returns a descriptive string' do + result = helper.session_device_icon(session) + + expect(result).to eq('desktop') + end + end + end +end diff --git a/spec/helpers/statuses_helper_spec.rb b/spec/helpers/statuses_helper_spec.rb index cba659bfb5b..c67e1f3f2b3 100644 --- a/spec/helpers/statuses_helper_spec.rb +++ b/spec/helpers/statuses_helper_spec.rb @@ -1,6 +1,96 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe StatusesHelper, type: :helper do +describe StatusesHelper do + describe 'status_text_summary' do + context 'with blank text' do + let(:status) { Status.new(spoiler_text: '') } + + it 'returns immediately with nil' do + result = helper.status_text_summary(status) + expect(result).to be_nil + end + end + + context 'with present text' do + let(:status) { Status.new(spoiler_text: 'SPOILERS!!!') } + + it 'returns the content warning' do + result = helper.status_text_summary(status) + expect(result).to eq(I18n.t('statuses.content_warning', warning: 'SPOILERS!!!')) + end + end + end + + def status_text_summary(status) + return if status.spoiler_text.blank? + + I18n.t('statuses.content_warning', warning: status.spoiler_text) + end + + describe 'link_to_newer' do + it 'returns a link to newer content' do + url = 'https://example.com' + result = helper.link_to_newer(url) + + expect(result).to match('load-more') + expect(result).to match(I18n.t('statuses.show_newer')) + end + end + + describe 'link_to_older' do + it 'returns a link to older content' do + url = 'https://example.com' + result = helper.link_to_older(url) + + expect(result).to match('load-more') + expect(result).to match(I18n.t('statuses.show_older')) + end + end + + describe 'fa_visibility_icon' do + context 'with a status that is public' do + let(:status) { Status.new(visibility: 'public') } + + it 'returns the correct fa icon' do + result = helper.fa_visibility_icon(status) + + expect(result).to match('fa-globe') + end + end + + context 'with a status that is unlisted' do + let(:status) { Status.new(visibility: 'unlisted') } + + it 'returns the correct fa icon' do + result = helper.fa_visibility_icon(status) + + expect(result).to match('fa-unlock') + end + end + + context 'with a status that is private' do + let(:status) { Status.new(visibility: 'private') } + + it 'returns the correct fa icon' do + result = helper.fa_visibility_icon(status) + + expect(result).to match('fa-lock') + end + end + + context 'with a status that is direct' do + let(:status) { Status.new(visibility: 'direct') } + + it 'returns the correct fa icon' do + result = helper.fa_visibility_icon(status) + + expect(result).to match('fa-at') + end + end + end + describe '#stream_link_target' do it 'returns nil if it is not an embedded view' do set_not_embedded_view @@ -24,129 +114,4 @@ RSpec.describe StatusesHelper, type: :helper do params[:controller] = StatusesHelper::EMBEDDED_CONTROLLER params[:action] = StatusesHelper::EMBEDDED_ACTION end - - describe '#style_classes' do - it do - status = double(reblog?: false) - classes = helper.style_classes(status, false, false, false) - - expect(classes).to eq 'entry' - end - - it do - status = double(reblog?: true) - classes = helper.style_classes(status, false, false, false) - - expect(classes).to eq 'entry entry-reblog' - end - - it do - status = double(reblog?: false) - classes = helper.style_classes(status, true, false, false) - - expect(classes).to eq 'entry entry-predecessor' - end - - it do - status = double(reblog?: false) - classes = helper.style_classes(status, false, true, false) - - expect(classes).to eq 'entry entry-successor' - end - - it do - status = double(reblog?: false) - classes = helper.style_classes(status, false, false, true) - - expect(classes).to eq 'entry entry-center' - end - - it do - status = double(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 = double(reblog?: false) - classes = helper.microformats_classes(status, false, false) - - expect(classes).to eq '' - end - - it do - status = double(reblog?: false) - classes = helper.microformats_classes(status, true, false) - - expect(classes).to eq 'p-in-reply-to' - end - - it do - status = double(reblog?: false) - classes = helper.microformats_classes(status, false, true) - - expect(classes).to eq 'p-comment' - end - - it do - status = double(reblog?: true) - classes = helper.microformats_classes(status, true, false) - - expect(classes).to eq 'p-in-reply-to p-repost-of' - end - - it do - status = double(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 = double(reblog?: false) - css_class = helper.microformats_h_class(status, false, false, false) - - expect(css_class).to eq 'h-entry' - end - - it do - status = double(reblog?: true) - css_class = helper.microformats_h_class(status, false, false, false) - - expect(css_class).to eq 'h-cite' - end - - it do - status = double(reblog?: false) - css_class = helper.microformats_h_class(status, true, false, false) - - expect(css_class).to eq 'h-cite' - end - - it do - status = double(reblog?: false) - css_class = helper.microformats_h_class(status, false, true, false) - - expect(css_class).to eq 'h-cite' - end - - it do - status = double(reblog?: false) - css_class = helper.microformats_h_class(status, false, false, true) - - expect(css_class).to eq '' - end - - it do - status = double(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 new file mode 100644 index 00000000000..e5d85656a20 --- /dev/null +++ b/spec/lib/account_reach_finder_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +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', 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', 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', domain: 'example.com') } + + before do + ap_follower_example_com.follow!(account) + ap_follower_example_org.follow!(account) + ap_follower_with_shared.follow!(account) + + Fabricate(:status, account: account).tap do |status| + status.mentions << Mention.new(account: ap_follower_example_com) + status.mentions << Mention.new(account: ap_mentioned_with_shared) + end + + Fabricate(:status, account: account) + + Fabricate(:status, account: account).tap do |status| + status.mentions << Mention.new(account: ap_mentioned_example_com) + status.mentions << Mention.new(account: ap_mentioned_example_org) + end + + Fabricate(:status).tap do |status| + status.mentions << Mention.new(account: unrelated_account) + end + end + + describe '#inboxes' do + it 'includes the preferred inbox URL of followers' do + expect(described_class.new(account).inboxes).to include(*[ap_follower_example_com, ap_follower_example_org, ap_follower_with_shared].map(&:preferred_inbox_url)) + end + + it 'includes the preferred inbox URL of recently-mentioned accounts' do + expect(described_class.new(account).inboxes).to include(*[ap_mentioned_with_shared, ap_mentioned_example_com, ap_mentioned_example_org].map(&:preferred_inbox_url)) + end + + it 'does not include the inbox of unrelated users' do + expect(described_class.new(account).inboxes).to_not include(unrelated_account.preferred_inbox_url) + end + end +end diff --git a/spec/models/account_statuses_filter_spec.rb b/spec/lib/account_statuses_filter_spec.rb similarity index 85% rename from spec/models/account_statuses_filter_spec.rb rename to spec/lib/account_statuses_filter_spec.rb index fa7664d9215..c821eb4bac0 100644 --- a/spec/models/account_statuses_filter_spec.rb +++ b/spec/lib/account_statuses_filter_spec.rb @@ -199,6 +199,34 @@ RSpec.describe AccountStatusesFilter do 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(subject.results.pluck(:id)).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(subject.results.pluck(:id)).to include(reblog.id) + end + end + context 'when muting a reblogged account' do let(:reblog) { status_with_reblog!('public') } diff --git a/spec/lib/activitypub/activity/accept_spec.rb b/spec/lib/activitypub/activity/accept_spec.rb index 95a5a8747d1..d6b60712794 100644 --- a/spec/lib/activitypub/activity/accept_spec.rb +++ b/spec/lib/activitypub/activity/accept_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Accept do @@ -41,7 +43,7 @@ RSpec.describe ActivityPub::Activity::Accept do end end - context 'given a relay' do + context 'when given a relay' do subject { described_class.new(json, sender) } let!(:relay) { Fabricate(:relay, state: :pending, follow_activity_id: 'https://abc-123/456') } diff --git a/spec/lib/activitypub/activity/add_spec.rb b/spec/lib/activitypub/activity/add_spec.rb index a69c3d2b19d..ec6df017166 100644 --- a/spec/lib/activitypub/activity/add_spec.rb +++ b/spec/lib/activitypub/activity/add_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Add do @@ -24,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Add do end context 'when status was not known before' do - let(:service_stub) { double } + let(:service_stub) { instance_double(ActivityPub::FetchRemoteStatusService) } let(:json) do { diff --git a/spec/lib/activitypub/activity/announce_spec.rb b/spec/lib/activitypub/activity/announce_spec.rb index b3257e881fe..8ad892975d1 100644 --- a/spec/lib/activitypub/activity/announce_spec.rb +++ b/spec/lib/activitypub/activity/announce_spec.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + 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) } @@ -37,7 +39,7 @@ RSpec.describe ActivityPub::Activity::Announce do subject.perform end - context 'a known status' do + context 'with known status' do let(:object_json) do ActivityPub::TagManager.instance.uri_for(status) end @@ -47,7 +49,7 @@ RSpec.describe ActivityPub::Activity::Announce do end end - context 'an unknown status' do + context 'with unknown status' do let(:object_json) { 'https://example.com/actor/hello-world' } it 'creates a reblog by sender of status' do @@ -58,7 +60,7 @@ RSpec.describe ActivityPub::Activity::Announce do end end - context 'self-boost of a previously unknown status with correct attributedTo' do + context 'when self-boost of a previously unknown status with correct attributedTo' do let(:object_json) do { id: 'https://example.com/actor#bar', @@ -74,7 +76,7 @@ RSpec.describe ActivityPub::Activity::Announce do end end - context 'self-boost of a previously unknown status with correct attributedTo, inlined Collection in audience' do + context 'when self-boost of a previously unknown status with correct attributedTo, inlined Collection in audience' do let(:object_json) do { id: 'https://example.com/actor#bar', @@ -112,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' } @@ -121,7 +123,7 @@ RSpec.describe ActivityPub::Activity::Announce do stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json)) end - context 'and the relay is enabled' do + context 'when the relay is enabled' do before do relay.update(state: :accepted) subject.perform @@ -133,7 +135,7 @@ RSpec.describe ActivityPub::Activity::Announce do end end - context 'and the relay is disabled' do + context 'when the relay is disabled' do before do subject.perform end diff --git a/spec/lib/activitypub/activity/block_spec.rb b/spec/lib/activitypub/activity/block_spec.rb index 42bdfdc810e..6f68984018c 100644 --- a/spec/lib/activitypub/activity/block_spec.rb +++ b/spec/lib/activitypub/activity/block_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Block do diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index fd498303748..f6c24754c01 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Create do @@ -29,7 +31,7 @@ RSpec.describe ActivityPub::Activity::Create do subject.perform end - context 'object has been edited' do + context 'when object has been edited' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -55,7 +57,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'object has update date equal to creation date' do + context 'when object has update date equal to creation date' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -81,7 +83,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'unknown object type' do + context 'with an unknown object type' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -95,7 +97,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'standalone' do + context 'with a standalone' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -119,7 +121,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'public with explicit public address' do + context 'when public with explicit public address' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -137,7 +139,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'public with as:Public' do + context 'when public with as:Public' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -155,7 +157,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'public with Public' do + context 'when public with Public' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -173,7 +175,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'unlisted with explicit public address' do + context 'when unlisted with explicit public address' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -191,7 +193,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'unlisted with as:Public' do + context 'when unlisted with as:Public' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -209,7 +211,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'unlisted with Public' do + context 'when unlisted with Public' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -227,7 +229,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'private' do + context 'when private' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -245,7 +247,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'private with inlined Collection in audience' do + context 'when private with inlined Collection in audience' do let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -267,7 +269,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'limited' do + context 'when limited' do let(:recipient) { Fabricate(:account) } let(:object_json) do @@ -292,7 +294,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'direct' do + context 'when direct' do let(:recipient) { Fabricate(:account) } let(:object_json) do @@ -316,7 +318,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'as a reply' do + context 'with a reply' do let(:original_status) { Fabricate(:status) } let(:object_json) do diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb index 40cd0fce954..3a73b3726c9 100644 --- a/spec/lib/activitypub/activity/delete_spec.rb +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Delete do diff --git a/spec/lib/activitypub/activity/flag_spec.rb b/spec/lib/activitypub/activity/flag_spec.rb index c2a50535671..8593d567f42 100644 --- a/spec/lib/activitypub/activity/flag_spec.rb +++ b/spec/lib/activitypub/activity/flag_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Flag do @@ -37,6 +39,37 @@ RSpec.describe ActivityPub::Activity::Flag do end end + context 'when the report comment is excessively long' do + subject do + described_class.new({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: flag_id, + type: 'Flag', + content: long_comment, + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: [ + ActivityPub::TagManager.instance.uri_for(flagged), + ActivityPub::TagManager.instance.uri_for(status), + ], + }.with_indifferent_access, sender) + end + + let(:long_comment) { Faker::Lorem.characters(number: 6000) } + + before do + subject.perform + end + + it 'creates a report but with a truncated comment' do + report = Report.find_by(account: sender, target_account: flagged) + + expect(report).to_not be_nil + expect(report.comment.length).to eq 5000 + expect(report.comment).to eq long_comment[0...5000] + expect(report.status_ids).to eq [status.id] + end + end + context 'when the reported status is private and should not be visible to the remote server' do let(:status) { Fabricate(:status, account: flagged, uri: 'foobar', visibility: :private) } @@ -106,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/follow_spec.rb b/spec/lib/activitypub/activity/follow_spec.rb index fd4ede82b7b..c1829cb8d71 100644 --- a/spec/lib/activitypub/activity/follow_spec.rb +++ b/spec/lib/activitypub/activity/follow_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Follow do @@ -18,7 +20,7 @@ RSpec.describe ActivityPub::Activity::Follow do subject { described_class.new(json, sender) } context 'with no prior follow' do - context 'unlocked account' do + context 'with an unlocked account' do before do subject.perform end @@ -33,7 +35,7 @@ RSpec.describe ActivityPub::Activity::Follow do end end - context 'silenced account following an unlocked account' do + context 'when silenced account following an unlocked account' do before do sender.touch(:silenced_at) subject.perform @@ -49,7 +51,7 @@ RSpec.describe ActivityPub::Activity::Follow do end end - context 'unlocked account muting the sender' do + context 'with an unlocked account muting the sender' do before do recipient.mute!(sender) subject.perform @@ -65,7 +67,7 @@ RSpec.describe ActivityPub::Activity::Follow do end end - context 'locked account' do + context 'when locked account' do before do recipient.update(locked: true) subject.perform @@ -87,7 +89,7 @@ RSpec.describe ActivityPub::Activity::Follow do sender.active_relationships.create!(target_account: recipient, uri: 'bar') end - context 'unlocked account' do + context 'with an unlocked account' do before do subject.perform end @@ -101,7 +103,7 @@ RSpec.describe ActivityPub::Activity::Follow do end end - context 'silenced account following an unlocked account' do + context 'when silenced account following an unlocked account' do before do sender.touch(:silenced_at) subject.perform @@ -116,7 +118,7 @@ RSpec.describe ActivityPub::Activity::Follow do end end - context 'unlocked account muting the sender' do + context 'with an unlocked account muting the sender' do before do recipient.mute!(sender) subject.perform @@ -131,7 +133,7 @@ RSpec.describe ActivityPub::Activity::Follow do end end - context 'locked account' do + context 'when locked account' do before do recipient.update(locked: true) subject.perform @@ -152,7 +154,7 @@ RSpec.describe ActivityPub::Activity::Follow do sender.follow_requests.create!(target_account: recipient, uri: 'bar') end - context 'silenced account following an unlocked account' do + context 'when silenced account following an unlocked account' do before do sender.touch(:silenced_at) subject.perform @@ -168,7 +170,7 @@ RSpec.describe ActivityPub::Activity::Follow do end end - context 'locked account' do + context 'when locked account' do before do recipient.update(locked: true) subject.perform diff --git a/spec/lib/activitypub/activity/like_spec.rb b/spec/lib/activitypub/activity/like_spec.rb index b69615a9d1b..640d61ab369 100644 --- a/spec/lib/activitypub/activity/like_spec.rb +++ b/spec/lib/activitypub/activity/like_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Like do diff --git a/spec/lib/activitypub/activity/move_spec.rb b/spec/lib/activitypub/activity/move_spec.rb index c468fdeffc8..f3973c70cea 100644 --- a/spec/lib/activitypub/activity/move_spec.rb +++ b/spec/lib/activitypub/activity/move_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Move do @@ -24,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Move do stub_request(:post, old_account.inbox_url).to_return(status: 200) stub_request(:post, new_account.inbox_url).to_return(status: 200) - service_stub = double + service_stub = instance_double(ActivityPub::FetchRemoteAccountService) allow(ActivityPub::FetchRemoteAccountService).to receive(:new).and_return(service_stub) allow(service_stub).to receive(:call).and_return(returned_account) end diff --git a/spec/lib/activitypub/activity/reject_spec.rb b/spec/lib/activitypub/activity/reject_spec.rb index 7f04db4e61f..0a4243cd16a 100644 --- a/spec/lib/activitypub/activity/reject_spec.rb +++ b/spec/lib/activitypub/activity/reject_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Reject do @@ -25,7 +27,7 @@ RSpec.describe ActivityPub::Activity::Reject do describe '#perform' do subject { described_class.new(json, sender) } - context 'rejecting a pending follow request by target' do + context 'when rejecting a pending follow request by target' do before do Fabricate(:follow_request, account: recipient, target_account: sender) subject.perform @@ -40,7 +42,7 @@ RSpec.describe ActivityPub::Activity::Reject do end end - context 'rejecting a pending follow request by uri' do + context 'when rejecting a pending follow request by uri' do before do Fabricate(:follow_request, account: recipient, target_account: sender, uri: 'bar') subject.perform @@ -55,7 +57,7 @@ RSpec.describe ActivityPub::Activity::Reject do end end - context 'rejecting a pending follow request by uri only' do + context 'when rejecting a pending follow request by uri only' do let(:object_json) { 'bar' } before do @@ -72,7 +74,7 @@ RSpec.describe ActivityPub::Activity::Reject do end end - context 'rejecting an existing follow relationship by target' do + context 'when rejecting an existing follow relationship by target' do before do Fabricate(:follow, account: recipient, target_account: sender) subject.perform @@ -87,7 +89,7 @@ RSpec.describe ActivityPub::Activity::Reject do end end - context 'rejecting an existing follow relationship by uri' do + context 'when rejecting an existing follow relationship by uri' do before do Fabricate(:follow, account: recipient, target_account: sender, uri: 'bar') subject.perform @@ -102,7 +104,7 @@ RSpec.describe ActivityPub::Activity::Reject do end end - context 'rejecting an existing follow relationship by uri only' do + context 'when rejecting an existing follow relationship by uri only' do let(:object_json) { 'bar' } before do @@ -120,7 +122,7 @@ RSpec.describe ActivityPub::Activity::Reject do end end - context 'given a relay' do + context 'when given a relay' do subject { described_class.new(json, sender) } let!(:relay) { Fabricate(:relay, state: :pending, follow_activity_id: 'https://abc-123/456') } diff --git a/spec/lib/activitypub/activity/remove_spec.rb b/spec/lib/activitypub/activity/remove_spec.rb index 4209dfde202..fc12aec8c1d 100644 --- a/spec/lib/activitypub/activity/remove_spec.rb +++ b/spec/lib/activitypub/activity/remove_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Remove do diff --git a/spec/lib/activitypub/activity/undo_spec.rb b/spec/lib/activitypub/activity/undo_spec.rb index 0bd1f17d351..58e71fc4e8d 100644 --- a/spec/lib/activitypub/activity/undo_spec.rb +++ b/spec/lib/activitypub/activity/undo_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Activity::Undo do @@ -29,7 +31,7 @@ RSpec.describe ActivityPub::Activity::Undo do } end - context do + context 'when not atomUri' do before do Fabricate(:status, reblog: status, account: sender, uri: 'bar') end diff --git a/spec/lib/activitypub/activity/update_spec.rb b/spec/lib/activitypub/activity/update_spec.rb index d2a1edd7a0a..87e96d2d1b1 100644 --- a/spec/lib/activitypub/activity/update_spec.rb +++ b/spec/lib/activitypub/activity/update_spec.rb @@ -1,24 +1,42 @@ +# frozen_string_literal: true + 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 @@ -26,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 @@ -36,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 @@ -47,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 e4c403abb9d..f9f8b8dce0d 100644 --- a/spec/lib/activitypub/adapter_spec.rb +++ b/spec/lib/activitypub/adapter_spec.rb @@ -1,43 +1,53 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Adapter do - class TestObject < ActiveModelSerializers::Model - attributes :foo - end - - class TestWithBasicContextSerializer < ActivityPub::Serializer - attributes :foo - end - - class TestWithNamedContextSerializer < ActivityPub::Serializer - context :security - attributes :foo - end - - class TestWithNestedNamedContextSerializer < ActivityPub::Serializer - attributes :foo - - has_one :virtual_object, key: :baz, serializer: TestWithNamedContextSerializer - - def virtual_object - object + before do + test_object_class = Class.new(ActiveModelSerializers::Model) do + attributes :foo end - end + stub_const('TestObject', test_object_class) - class TestWithContextExtensionSerializer < ActivityPub::Serializer - context_extensions :sensitive - attributes :foo - end - - class TestWithNestedContextExtensionSerializer < ActivityPub::Serializer - context_extensions :manually_approves_followers - attributes :foo - - has_one :virtual_object, key: :baz, serializer: TestWithContextExtensionSerializer - - def virtual_object - object + test_with_basic_context_serializer = Class.new(ActivityPub::Serializer) do + attributes :foo end + stub_const('TestWithBasicContextSerializer', test_with_basic_context_serializer) + + test_with_named_context_serializer = Class.new(ActivityPub::Serializer) do + context :security + attributes :foo + end + stub_const('TestWithNamedContextSerializer', test_with_named_context_serializer) + + test_with_nested_named_context_serializer = Class.new(ActivityPub::Serializer) do + attributes :foo + + has_one :virtual_object, key: :baz, serializer: TestWithNamedContextSerializer + + def virtual_object + object + end + end + stub_const('TestWithNestedNamedContextSerializer', test_with_nested_named_context_serializer) + + test_with_context_extension_serializer = Class.new(ActivityPub::Serializer) do + context_extensions :sensitive + attributes :foo + end + stub_const('TestWithContextExtensionSerializer', test_with_context_extension_serializer) + + test_with_nested_context_extension_serializer = Class.new(ActivityPub::Serializer) do + context_extensions :manually_approves_followers + attributes :foo + + has_one :virtual_object, key: :baz, serializer: TestWithContextExtensionSerializer + + def virtual_object + object + end + end + stub_const('TestWithNestedContextExtensionSerializer', test_with_nested_context_extension_serializer) end describe '#serializable_hash' do diff --git a/spec/lib/activitypub/dereferencer_spec.rb b/spec/lib/activitypub/dereferencer_spec.rb index 800473a7ca6..11078de866e 100644 --- a/spec/lib/activitypub/dereferencer_spec.rb +++ b/spec/lib/activitypub/dereferencer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::Dereferencer do diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb index ecb1e16db78..97268eea6d0 100644 --- a/spec/lib/activitypub/linked_data_signature_spec.rb +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::LinkedDataSignature do @@ -5,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 { @@ -16,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 @@ -36,6 +34,40 @@ RSpec.describe ActivityPub::LinkedDataSignature do end end + context 'when local account record is missing a public key' do + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) } + + let(:service_stub) { instance_double(ActivityPub::FetchRemoteKeyService) } + + before do + # Ensure signature is computed with the old key + signature + + # Unset key + old_key = sender.public_key + sender.update!(private_key: '', public_key: '') + + allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub) + + allow(service_stub).to receive(:call).with('http://example.com/alice', id: false) do + sender.update!(public_key: old_key) + sender + end + end + + it 'fetches key and returns creator' do + expect(subject.verify_actor!).to eq sender + expect(service_stub).to have_received(:call).with('http://example.com/alice', id: false).once + end + end + context 'when signature is missing' do let(:signature) { nil } diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb index 606a1de2e56..2bff125a6ae 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::TagManager do @@ -137,7 +139,7 @@ RSpec.describe ActivityPub::TagManager do end it 'returns the remote account by matching URI without fragment part' do - account = Fabricate(:account, uri: 'https://example.com/123') + account = Fabricate(:account, uri: 'https://example.com/123', domain: 'example.com') expect(subject.uri_to_resource('https://example.com/123#456', Account)).to eq account end diff --git a/spec/lib/admin/metrics/dimension/instance_accounts_dimension_spec.rb b/spec/lib/admin/metrics/dimension/instance_accounts_dimension_spec.rb new file mode 100644 index 00000000000..106717f97b9 --- /dev/null +++ b/spec/lib/admin/metrics/dimension/instance_accounts_dimension_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Dimension::InstanceAccountsDimension do + subject(:dimension) { described_class.new(start_at, end_at, limit, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:limit) { 10 } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { dimension.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/dimension/instance_languages_dimension_spec.rb b/spec/lib/admin/metrics/dimension/instance_languages_dimension_spec.rb new file mode 100644 index 00000000000..f9f6430ca0d --- /dev/null +++ b/spec/lib/admin/metrics/dimension/instance_languages_dimension_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Dimension::InstanceLanguagesDimension do + subject(:dimension) { described_class.new(start_at, end_at, limit, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:limit) { 10 } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { dimension.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/dimension/languages_dimension_spec.rb b/spec/lib/admin/metrics/dimension/languages_dimension_spec.rb new file mode 100644 index 00000000000..1722c4c6166 --- /dev/null +++ b/spec/lib/admin/metrics/dimension/languages_dimension_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Dimension::LanguagesDimension do + subject(:dimension) { described_class.new(start_at, end_at, limit, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:limit) { 10 } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { dimension.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/dimension/servers_dimension_spec.rb b/spec/lib/admin/metrics/dimension/servers_dimension_spec.rb new file mode 100644 index 00000000000..7e2bb9ac0b7 --- /dev/null +++ b/spec/lib/admin/metrics/dimension/servers_dimension_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Dimension::ServersDimension do + subject(:dimension) { described_class.new(start_at, end_at, limit, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:limit) { 10 } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { dimension.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/dimension/software_versions_dimension_spec.rb b/spec/lib/admin/metrics/dimension/software_versions_dimension_spec.rb new file mode 100644 index 00000000000..ee14917330f --- /dev/null +++ b/spec/lib/admin/metrics/dimension/software_versions_dimension_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Dimension::SoftwareVersionsDimension do + subject(:dimension) { described_class.new(start_at, end_at, limit, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:limit) { 10 } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { dimension.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/dimension/sources_dimension_spec.rb b/spec/lib/admin/metrics/dimension/sources_dimension_spec.rb new file mode 100644 index 00000000000..d6b581a9bbe --- /dev/null +++ b/spec/lib/admin/metrics/dimension/sources_dimension_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Dimension::SourcesDimension do + subject(:dimension) { described_class.new(start_at, end_at, limit, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:limit) { 10 } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { dimension.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/dimension/space_usage_dimension_spec.rb b/spec/lib/admin/metrics/dimension/space_usage_dimension_spec.rb new file mode 100644 index 00000000000..65d04cfedd8 --- /dev/null +++ b/spec/lib/admin/metrics/dimension/space_usage_dimension_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Dimension::SpaceUsageDimension do + subject(:dimension) { described_class.new(start_at, end_at, limit, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:limit) { 10 } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { dimension.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/dimension/tag_languages_dimension_spec.rb b/spec/lib/admin/metrics/dimension/tag_languages_dimension_spec.rb new file mode 100644 index 00000000000..721d24fa188 --- /dev/null +++ b/spec/lib/admin/metrics/dimension/tag_languages_dimension_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Dimension::TagLanguagesDimension do + subject(:dimension) { described_class.new(start_at, end_at, limit, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:limit) { 10 } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { dimension.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/dimension/tag_servers_dimension_spec.rb b/spec/lib/admin/metrics/dimension/tag_servers_dimension_spec.rb new file mode 100644 index 00000000000..30547168161 --- /dev/null +++ b/spec/lib/admin/metrics/dimension/tag_servers_dimension_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Dimension::TagServersDimension do + subject(:dimension) { described_class.new(start_at, end_at, limit, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:limit) { 10 } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { dimension.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/active_users_measure_spec.rb b/spec/lib/admin/metrics/measure/active_users_measure_spec.rb new file mode 100644 index 00000000000..55164ed88ac --- /dev/null +++ b/spec/lib/admin/metrics/measure/active_users_measure_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::ActiveUsersMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/instance_accounts_measure_spec.rb b/spec/lib/admin/metrics/measure/instance_accounts_measure_spec.rb new file mode 100644 index 00000000000..8e414963f33 --- /dev/null +++ b/spec/lib/admin/metrics/measure/instance_accounts_measure_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::InstanceAccountsMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let(:domain) { 'example.com' } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + + let(:params) { ActionController::Parameters.new(domain: domain) } + + before do + Fabricate(:account, domain: domain, created_at: 1.year.ago) + Fabricate(:account, domain: domain, created_at: 1.month.ago) + Fabricate(:account, domain: domain) + + Fabricate(:account, domain: "foo.#{domain}", created_at: 1.year.ago) + Fabricate(:account, domain: "foo.#{domain}") + Fabricate(:account, domain: "bar.#{domain}") + end + + describe 'total' do + context 'without include_subdomains' do + it 'returns the expected number of accounts' do + expect(measure.total).to eq 3 + end + end + + context 'with include_subdomains' do + let(:params) { ActionController::Parameters.new(domain: domain, include_subdomains: 'true') } + + it 'returns the expected number of accounts' do + expect(measure.total).to eq 6 + end + end + end + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/instance_followers_measure_spec.rb b/spec/lib/admin/metrics/measure/instance_followers_measure_spec.rb new file mode 100644 index 00000000000..c627e6cede0 --- /dev/null +++ b/spec/lib/admin/metrics/measure/instance_followers_measure_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::InstanceFollowersMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let(:domain) { 'example.com' } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + + let(:params) { ActionController::Parameters.new(domain: domain) } + + before do + local_account = Fabricate(:account) + + Fabricate(:account, domain: domain).follow!(local_account) + Fabricate(:account, domain: domain).follow!(local_account) + Fabricate(:account, domain: domain) + + Fabricate(:account, domain: "foo.#{domain}").follow!(local_account) + Fabricate(:account, domain: "foo.#{domain}").follow!(local_account) + Fabricate(:account, domain: "bar.#{domain}") + end + + describe 'total' do + context 'without include_subdomains' do + it 'returns the expected number of accounts' do + expect(measure.total).to eq 2 + end + end + + context 'with include_subdomains' do + let(:params) { ActionController::Parameters.new(domain: domain, include_subdomains: 'true') } + + it 'returns the expected number of accounts' do + expect(measure.total).to eq 4 + end + end + end + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/instance_follows_measure_spec.rb b/spec/lib/admin/metrics/measure/instance_follows_measure_spec.rb new file mode 100644 index 00000000000..42f33dfc35f --- /dev/null +++ b/spec/lib/admin/metrics/measure/instance_follows_measure_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::InstanceFollowsMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let(:domain) { 'example.com' } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + + let(:params) { ActionController::Parameters.new(domain: domain) } + + before do + local_account = Fabricate(:account) + + local_account.follow!(Fabricate(:account, domain: domain)) + local_account.follow!(Fabricate(:account, domain: domain)) + Fabricate(:account, domain: domain) + + local_account.follow!(Fabricate(:account, domain: "foo.#{domain}")) + local_account.follow!(Fabricate(:account, domain: "foo.#{domain}")) + Fabricate(:account, domain: "bar.#{domain}") + end + + describe 'total' do + context 'without include_subdomains' do + it 'returns the expected number of accounts' do + expect(measure.total).to eq 2 + end + end + + context 'with include_subdomains' do + let(:params) { ActionController::Parameters.new(domain: domain, include_subdomains: 'true') } + + it 'returns the expected number of accounts' do + expect(measure.total).to eq 4 + end + end + end + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/instance_media_attachments_measure_spec.rb b/spec/lib/admin/metrics/measure/instance_media_attachments_measure_spec.rb new file mode 100644 index 00000000000..c103307f971 --- /dev/null +++ b/spec/lib/admin/metrics/measure/instance_media_attachments_measure_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::InstanceMediaAttachmentsMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let(:domain) { 'example.com' } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + + let(:params) { ActionController::Parameters.new(domain: domain) } + + let(:remote_account) { Fabricate(:account, domain: domain) } + let(:remote_account_on_subdomain) { Fabricate(:account, domain: "foo.#{domain}") } + + before do + remote_account.media_attachments.create!(file: attachment_fixture('attachment.jpg')) + remote_account_on_subdomain.media_attachments.create!(file: attachment_fixture('attachment.jpg')) + end + + describe 'total' do + context 'without include_subdomains' do + it 'returns the expected number of accounts' do + expected_total = remote_account.media_attachments.sum(:file_file_size) + remote_account.media_attachments.sum(:thumbnail_file_size) + expect(measure.total).to eq expected_total + end + end + + context 'with include_subdomains' do + let(:params) { ActionController::Parameters.new(domain: domain, include_subdomains: 'true') } + + it 'returns the expected number of accounts' do + expected_total = [remote_account, remote_account_on_subdomain].sum do |account| + account.media_attachments.sum(:file_file_size) + account.media_attachments.sum(:thumbnail_file_size) + end + + expect(measure.total).to eq expected_total + end + end + end + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/instance_reports_measure_spec.rb b/spec/lib/admin/metrics/measure/instance_reports_measure_spec.rb new file mode 100644 index 00000000000..62fcf84ac13 --- /dev/null +++ b/spec/lib/admin/metrics/measure/instance_reports_measure_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::InstanceReportsMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let(:domain) { 'example.com' } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + + let(:params) { ActionController::Parameters.new(domain: domain) } + + before do + Fabricate(:report, target_account: Fabricate(:account, domain: domain)) + Fabricate(:report, target_account: Fabricate(:account, domain: domain)) + + Fabricate(:report, target_account: Fabricate(:account, domain: "foo.#{domain}")) + Fabricate(:report, target_account: Fabricate(:account, domain: "foo.#{domain}")) + Fabricate(:report, target_account: Fabricate(:account, domain: "bar.#{domain}")) + end + + describe 'total' do + context 'without include_subdomains' do + it 'returns the expected number of accounts' do + expect(measure.total).to eq 2 + end + end + + context 'with include_subdomains' do + let(:params) { ActionController::Parameters.new(domain: domain, include_subdomains: 'true') } + + it 'returns the expected number of accounts' do + expect(measure.total).to eq 5 + end + end + end + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/instance_statuses_measure_spec.rb b/spec/lib/admin/metrics/measure/instance_statuses_measure_spec.rb new file mode 100644 index 00000000000..df4cfe207bc --- /dev/null +++ b/spec/lib/admin/metrics/measure/instance_statuses_measure_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::InstanceStatusesMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let(:domain) { 'example.com' } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + + let(:params) { ActionController::Parameters.new(domain: domain) } + + before do + Fabricate(:status, account: Fabricate(:account, domain: domain)) + Fabricate(:status, account: Fabricate(:account, domain: domain)) + + Fabricate(:status, account: Fabricate(:account, domain: "foo.#{domain}")) + Fabricate(:status, account: Fabricate(:account, domain: "foo.#{domain}")) + Fabricate(:status, account: Fabricate(:account, domain: "bar.#{domain}")) + end + + describe 'total' do + context 'without include_subdomains' do + it 'returns the expected number of accounts' do + expect(measure.total).to eq 2 + end + end + + context 'with include_subdomains' do + let(:params) { ActionController::Parameters.new(domain: domain, include_subdomains: 'true') } + + it 'returns the expected number of accounts' do + expect(measure.total).to eq 5 + end + end + end + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/interactions_measure_spec.rb b/spec/lib/admin/metrics/measure/interactions_measure_spec.rb new file mode 100644 index 00000000000..e98c8305989 --- /dev/null +++ b/spec/lib/admin/metrics/measure/interactions_measure_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::InteractionsMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/new_users_measure_spec.rb b/spec/lib/admin/metrics/measure/new_users_measure_spec.rb new file mode 100644 index 00000000000..fe82f8219b1 --- /dev/null +++ b/spec/lib/admin/metrics/measure/new_users_measure_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::NewUsersMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/opened_reports_measure_spec.rb b/spec/lib/admin/metrics/measure/opened_reports_measure_spec.rb new file mode 100644 index 00000000000..deed64ae888 --- /dev/null +++ b/spec/lib/admin/metrics/measure/opened_reports_measure_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::OpenedReportsMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/resolved_reports_measure_spec.rb b/spec/lib/admin/metrics/measure/resolved_reports_measure_spec.rb new file mode 100644 index 00000000000..cb98df2dc24 --- /dev/null +++ b/spec/lib/admin/metrics/measure/resolved_reports_measure_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::ResolvedReportsMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:params) { ActionController::Parameters.new } + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/tag_accounts_measure_spec.rb b/spec/lib/admin/metrics/measure/tag_accounts_measure_spec.rb new file mode 100644 index 00000000000..938b67afa32 --- /dev/null +++ b/spec/lib/admin/metrics/measure/tag_accounts_measure_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::TagAccountsMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let!(:tag) { Fabricate(:tag) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:params) { ActionController::Parameters.new(id: tag.id) } + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/tag_servers_measure_spec.rb b/spec/lib/admin/metrics/measure/tag_servers_measure_spec.rb new file mode 100644 index 00000000000..e09a2b04e5f --- /dev/null +++ b/spec/lib/admin/metrics/measure/tag_servers_measure_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::TagServersMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let!(:tag) { Fabricate(:tag) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:params) { ActionController::Parameters.new(id: tag.id) } + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/metrics/measure/tag_uses_measure_spec.rb b/spec/lib/admin/metrics/measure/tag_uses_measure_spec.rb new file mode 100644 index 00000000000..869e9374459 --- /dev/null +++ b/spec/lib/admin/metrics/measure/tag_uses_measure_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::Metrics::Measure::TagUsesMeasure do + subject(:measure) { described_class.new(start_at, end_at, params) } + + let!(:tag) { Fabricate(:tag) } + + let(:start_at) { 2.days.ago } + let(:end_at) { Time.now.utc } + let(:params) { ActionController::Parameters.new(id: tag.id) } + + describe '#data' do + it 'runs data query without error' do + expect { measure.data }.to_not raise_error + end + end +end diff --git a/spec/lib/admin/system_check/base_check_spec.rb b/spec/lib/admin/system_check/base_check_spec.rb new file mode 100644 index 00000000000..fdd9f6b6c44 --- /dev/null +++ b/spec/lib/admin/system_check/base_check_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::SystemCheck::BaseCheck do + subject(:check) { described_class.new(user) } + + let(:user) { Fabricate(:user) } + + describe 'skip?' do + it 'returns false' do + expect(check.skip?).to be false + end + end + + describe 'pass?' do + it 'raises not implemented error' do + expect { check.pass? }.to raise_error(NotImplementedError) + end + end + + describe 'message' do + it 'raises not implemented error' do + expect { check.message }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/lib/admin/system_check/database_schema_check_spec.rb b/spec/lib/admin/system_check/database_schema_check_spec.rb new file mode 100644 index 00000000000..db1dcb52fa4 --- /dev/null +++ b/spec/lib/admin/system_check/database_schema_check_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::SystemCheck::DatabaseSchemaCheck do + subject(:check) { described_class.new(user) } + + let(:user) { Fabricate(:user) } + + it_behaves_like 'a check available to devops users' + + describe 'pass?' do + context 'when database needs migration' do + before do + context = instance_double(ActiveRecord::MigrationContext, needs_migration?: true) + allow(ActiveRecord::Base.connection).to receive(:migration_context).and_return(context) + end + + it 'returns false' do + expect(check.pass?).to be false + end + end + + context 'when database does not need migration' do + before do + context = instance_double(ActiveRecord::MigrationContext, needs_migration?: false) + allow(ActiveRecord::Base.connection).to receive(:migration_context).and_return(context) + end + + it 'returns true' do + expect(check.pass?).to be true + end + end + end + + describe 'message' do + it 'sends class name symbol to message instance' do + allow(Admin::SystemCheck::Message).to receive(:new).with(:database_schema_check) + + check.message + + expect(Admin::SystemCheck::Message).to have_received(:new).with(:database_schema_check) + end + end +end diff --git a/spec/lib/admin/system_check/elasticsearch_check_spec.rb b/spec/lib/admin/system_check/elasticsearch_check_spec.rb new file mode 100644 index 00000000000..a885640ce0c --- /dev/null +++ b/spec/lib/admin/system_check/elasticsearch_check_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::SystemCheck::ElasticsearchCheck do + subject(:check) { described_class.new(user) } + + let(:user) { Fabricate(:user) } + + it_behaves_like 'a check available to devops users' + + describe 'pass?' do + context 'when chewy is enabled' 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_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 + allow(Chewy.client).to receive(:info) + .and_return({ 'version' => { 'number' => '999.99.9' } }) + end + + it 'returns true' do + expect(check.pass?).to be true + end + end + + context 'when running version is present and too low' do + context 'when compatible version is too low' do + before do + allow(Chewy.client).to receive(:info) + .and_return({ 'version' => { 'number' => '1.2.3', 'minimum_wire_compatibility_version' => '1.0' } }) + end + + it 'returns false' do + expect(check.pass?).to be false + end + end + + context 'when compatible version is high enough' do + before do + allow(Chewy.client).to receive(:info) + .and_return({ 'version' => { 'number' => '1.2.3', 'minimum_wire_compatibility_version' => '99.9' } }) + end + + it 'returns true' do + expect(check.pass?).to be true + end + end + end + + context 'when running version is missing' do + before { stub_elasticsearch_error } + + it 'returns false' do + expect(check.pass?).to be false + end + end + end + + context 'when chewy is not enabled' do + before { allow(Chewy).to receive(:enabled?).and_return(false) } + + it 'returns true' do + expect(check.pass?).to be true + end + end + 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' => '1.2.3' } }) } + + it 'sends class name symbol to message instance' do + allow(Admin::SystemCheck::Message).to receive(:new) + .with(:elasticsearch_version_check, anything) + + check.message + + expect(Admin::SystemCheck::Message).to have_received(:new) + .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) + + check.message + + expect(Admin::SystemCheck::Message).to have_received(:new) + .with(:elasticsearch_running_check) + 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/media_privacy_check_spec.rb b/spec/lib/admin/system_check/media_privacy_check_spec.rb new file mode 100644 index 00000000000..316bf121561 --- /dev/null +++ b/spec/lib/admin/system_check/media_privacy_check_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::SystemCheck::MediaPrivacyCheck do + subject(:check) { described_class.new(user) } + + let(:user) { Fabricate(:user) } + + it_behaves_like 'a check available to devops users' + + describe 'pass?' do + context 'when the media cannot be listed' do + before do + stub_request(:get, /ngrok.io/).to_return(status: 200, body: 'a list of no files') + end + + it 'returns true' do + expect(check.pass?).to be true + end + end + end + + describe 'message' do + it 'sends values to message instance' do + allow(Admin::SystemCheck::Message).to receive(:new).with(nil, nil, nil, true) + + check.message + + expect(Admin::SystemCheck::Message).to have_received(:new).with(nil, nil, nil, true) + end + end +end diff --git a/spec/lib/admin/system_check/message_spec.rb b/spec/lib/admin/system_check/message_spec.rb new file mode 100644 index 00000000000..c0671f34525 --- /dev/null +++ b/spec/lib/admin/system_check/message_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::SystemCheck::Message do + subject(:check) { described_class.new(:key_value, :value_value, :action_value, :critical_value) } + + it 'providers readers when initialized' do + expect(check.key).to eq :key_value + expect(check.value).to eq :value_value + expect(check.action).to eq :action_value + expect(check.critical).to eq :critical_value + end +end diff --git a/spec/lib/admin/system_check/rules_check_spec.rb b/spec/lib/admin/system_check/rules_check_spec.rb new file mode 100644 index 00000000000..fb3293fb2d0 --- /dev/null +++ b/spec/lib/admin/system_check/rules_check_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::SystemCheck::RulesCheck do + subject(:check) { described_class.new(user) } + + let(:user) { Fabricate(:user) } + + describe 'skip?' do + context 'when user can manage rules' do + before { allow(user).to receive(:can?).with(:manage_rules).and_return(true) } + + it 'returns false' do + expect(check.skip?).to be false + end + end + + context 'when user cannot manage rules' do + before { allow(user).to receive(:can?).with(:manage_rules).and_return(false) } + + it 'returns true' do + expect(check.skip?).to be true + end + end + end + + describe 'pass?' do + context 'when there is not a kept rule' do + it 'returns false' do + expect(check.pass?).to be false + end + end + + context 'when there is a kept rule' do + before { Fabricate(:rule) } + + it 'returns true' do + expect(check.pass?).to be true + end + end + end + + describe 'message' do + it 'sends class name symbol to message instance' do + allow(Admin::SystemCheck::Message).to receive(:new).with(:rules_check, nil, '/admin/rules') + + check.message + + expect(Admin::SystemCheck::Message).to have_received(:new).with(:rules_check, nil, '/admin/rules') + end + end +end diff --git a/spec/lib/admin/system_check/sidekiq_process_check_spec.rb b/spec/lib/admin/system_check/sidekiq_process_check_spec.rb new file mode 100644 index 00000000000..9bd9daddf67 --- /dev/null +++ b/spec/lib/admin/system_check/sidekiq_process_check_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::SystemCheck::SidekiqProcessCheck do + subject(:check) { described_class.new(user) } + + let(:user) { Fabricate(:user) } + + it_behaves_like 'a check available to devops users' + + describe 'pass?' do + context 'when missing queues is empty' do + before do + process_set = instance_double(Sidekiq::ProcessSet, reduce: []) + allow(Sidekiq::ProcessSet).to receive(:new).and_return(process_set) + end + + it 'returns true' do + expect(check.pass?).to be true + end + end + + context 'when missing queues is not empty' do + before do + process_set = instance_double(Sidekiq::ProcessSet, reduce: [:something]) + allow(Sidekiq::ProcessSet).to receive(:new).and_return(process_set) + end + + it 'returns false' do + expect(check.pass?).to be false + end + end + end + + describe 'message' do + it 'sends values to message instance' do + allow(Admin::SystemCheck::Message).to receive(:new).with(:sidekiq_process_check, 'default, push, mailers, pull, scheduler, ingress') + + check.message + + expect(Admin::SystemCheck::Message).to have_received(:new).with(:sidekiq_process_check, 'default, push, mailers, pull, scheduler, ingress') + end + 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 00000000000..de4335fc519 --- /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/admin/system_check_spec.rb b/spec/lib/admin/system_check_spec.rb new file mode 100644 index 00000000000..30048fd3ade --- /dev/null +++ b/spec/lib/admin/system_check_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::SystemCheck do + let(:user) { Fabricate(:user) } + + describe 'perform' do + let(:result) { described_class.perform(user) } + + it 'runs all the checks' do + expect(result).to be_an(Array) + end + end +end diff --git a/spec/lib/cache_buster_spec.rb b/spec/lib/cache_buster_spec.rb new file mode 100644 index 00000000000..78ca183490e --- /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/connection_pool/shared_connection_pool_spec.rb b/spec/lib/connection_pool/shared_connection_pool_spec.rb index 1144645580b..a2fe75f742a 100644 --- a/spec/lib/connection_pool/shared_connection_pool_spec.rb +++ b/spec/lib/connection_pool/shared_connection_pool_spec.rb @@ -3,22 +3,24 @@ require 'rails_helper' describe ConnectionPool::SharedConnectionPool do - class MiniConnection - attr_reader :site + subject { described_class.new(size: 5, timeout: 5) { |site| mini_connection_class.new(site) } } - def initialize(site) - @site = site + let(:mini_connection_class) do + Class.new do + attr_reader :site + + def initialize(site) + @site = site + end end end - subject { described_class.new(size: 5, timeout: 5) { |site| MiniConnection.new(site) } } - describe '#with' do it 'runs a block with a connection' do block_run = false subject.with('foo') do |connection| - expect(connection).to be_a MiniConnection + expect(connection).to be_a mini_connection_class block_run = true end diff --git a/spec/lib/connection_pool/shared_timed_stack_spec.rb b/spec/lib/connection_pool/shared_timed_stack_spec.rb index f680c596670..04d550eec57 100644 --- a/spec/lib/connection_pool/shared_timed_stack_spec.rb +++ b/spec/lib/connection_pool/shared_timed_stack_spec.rb @@ -3,30 +3,32 @@ require 'rails_helper' describe ConnectionPool::SharedTimedStack do - class MiniConnection - attr_reader :site + subject { described_class.new(5) { |site| mini_connection_class.new(site) } } - def initialize(site) - @site = site + let(:mini_connection_class) do + Class.new do + attr_reader :site + + def initialize(site) + @site = site + end end end - subject { described_class.new(5) { |site| MiniConnection.new(site) } } - describe '#push' do it 'keeps the connection in the stack' do - subject.push(MiniConnection.new('foo')) + subject.push(mini_connection_class.new('foo')) expect(subject.size).to eq 1 end end describe '#pop' do it 'returns a connection' do - expect(subject.pop('foo')).to be_a MiniConnection + expect(subject.pop('foo')).to be_a mini_connection_class end it 'returns the same connection that was pushed in' do - connection = MiniConnection.new('foo') + connection = mini_connection_class.new('foo') subject.push(connection) expect(subject.pop('foo')).to be connection end @@ -36,8 +38,8 @@ describe ConnectionPool::SharedTimedStack do end it 'repurposes a connection for a different site when maximum amount is reached' do - 5.times { subject.push(MiniConnection.new('foo')) } - expect(subject.pop('bar')).to be_a MiniConnection + 5.times { subject.push(mini_connection_class.new('foo')) } + expect(subject.pop('bar')).to be_a mini_connection_class end end @@ -47,14 +49,14 @@ describe ConnectionPool::SharedTimedStack do end it 'returns false when there are connections on the stack' do - subject.push(MiniConnection.new('foo')) + subject.push(mini_connection_class.new('foo')) expect(subject.empty?).to be false end end describe '#size' do it 'returns the number of connections on the stack' do - 2.times { subject.push(MiniConnection.new('foo')) } + 2.times { subject.push(mini_connection_class.new('foo')) } expect(subject.size).to eq 2 end end diff --git a/spec/lib/emoji_formatter_spec.rb b/spec/lib/emoji_formatter_spec.rb index c6fe8cf377e..e5accfbb0cb 100644 --- a/spec/lib/emoji_formatter_spec.rb +++ b/spec/lib/emoji_formatter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe EmojiFormatter do @@ -12,7 +14,7 @@ RSpec.describe EmojiFormatter do let(:emojis) { [emoji] } - context 'given text that is not marked as html-safe' do + context 'when given text that is not marked as html-safe' do let(:text) { 'Foo' } it 'raises an argument error' do @@ -20,7 +22,7 @@ RSpec.describe EmojiFormatter do end end - context 'given text with an emoji shortcode at the start' do + context 'when given text with an emoji shortcode at the start' do let(:text) { preformat_text(':coolcat: Beep boop') } it 'converts the shortcode to an image tag' do @@ -28,7 +30,7 @@ RSpec.describe EmojiFormatter do end end - context 'given text with an emoji shortcode in the middle' do + context 'when given text with an emoji shortcode in the middle' do let(:text) { preformat_text('Beep :coolcat: boop') } it 'converts the shortcode to an image tag' do @@ -36,7 +38,7 @@ RSpec.describe EmojiFormatter do end end - context 'given text with concatenated emoji shortcodes' do + context 'when given text with concatenated emoji shortcodes' do let(:text) { preformat_text(':coolcat::coolcat:') } it 'does not touch the shortcodes' do @@ -44,7 +46,7 @@ RSpec.describe EmojiFormatter do end end - context 'given text with an emoji shortcode at the end' do + context 'when given text with an emoji shortcode at the end' do let(:text) { preformat_text('Beep boop :coolcat:') } it 'converts the shortcode to an image tag' do diff --git a/spec/lib/entity_cache_spec.rb b/spec/lib/entity_cache_spec.rb index bd622e626f1..5818de7119f 100644 --- a/spec/lib/entity_cache_spec.rb +++ b/spec/lib/entity_cache_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe EntityCache do @@ -5,9 +7,9 @@ RSpec.describe EntityCache do let(:remote_account) { Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/') } describe '#emoji' do - subject { EntityCache.instance.emoji(shortcodes, domain) } + subject { described_class.instance.emoji(shortcodes, domain) } - context 'called with an empty list of shortcodes' do + context 'when called with an empty list of shortcodes' do let(:shortcodes) { [] } let(:domain) { 'example.org' } diff --git a/spec/lib/extractor_spec.rb b/spec/lib/extractor_spec.rb index 560617ed7d5..b6c910171d3 100644 --- a/spec/lib/extractor_spec.rb +++ b/spec/lib/extractor_spec.rb @@ -6,19 +6,19 @@ describe Extractor do describe 'extract_mentions_or_lists_with_indices' do it 'returns an empty array if the given string does not have at signs' do text = 'a string without at signs' - extracted = Extractor.extract_mentions_or_lists_with_indices(text) + extracted = described_class.extract_mentions_or_lists_with_indices(text) expect(extracted).to eq [] end it 'does not extract mentions which ends with particular characters' do text = '@screen_name@' - extracted = Extractor.extract_mentions_or_lists_with_indices(text) + extracted = described_class.extract_mentions_or_lists_with_indices(text) expect(extracted).to eq [] end it 'returns mentions as an array' do text = '@screen_name' - extracted = Extractor.extract_mentions_or_lists_with_indices(text) + extracted = described_class.extract_mentions_or_lists_with_indices(text) expect(extracted).to eq [ { screen_name: 'screen_name', indices: [0, 12] }, ] @@ -26,7 +26,7 @@ describe Extractor do it 'yields mentions if a block is given' do text = '@screen_name' - Extractor.extract_mentions_or_lists_with_indices(text) do |screen_name, start_position, end_position| + described_class.extract_mentions_or_lists_with_indices(text) do |screen_name, start_position, end_position| expect(screen_name).to eq 'screen_name' expect(start_position).to eq 0 expect(end_position).to eq 12 @@ -37,31 +37,31 @@ describe Extractor do describe 'extract_hashtags_with_indices' do it 'returns an empty array if it does not have #' do text = 'a string without hash sign' - extracted = Extractor.extract_hashtags_with_indices(text) + extracted = described_class.extract_hashtags_with_indices(text) expect(extracted).to eq [] end it 'does not exclude normal hash text before ://' do text = '#hashtag://' - extracted = Extractor.extract_hashtags_with_indices(text) + extracted = described_class.extract_hashtags_with_indices(text) expect(extracted).to eq [{ hashtag: 'hashtag', indices: [0, 8] }] end it 'excludes http://' do text = '#hashtaghttp://' - extracted = Extractor.extract_hashtags_with_indices(text) + extracted = described_class.extract_hashtags_with_indices(text) expect(extracted).to eq [{ hashtag: 'hashtag', indices: [0, 8] }] end it 'excludes https://' do text = '#hashtaghttps://' - extracted = Extractor.extract_hashtags_with_indices(text) + extracted = described_class.extract_hashtags_with_indices(text) expect(extracted).to eq [{ hashtag: 'hashtag', indices: [0, 8] }] end it 'yields hashtags if a block is given' do text = '#hashtag' - Extractor.extract_hashtags_with_indices(text) do |hashtag, start_position, end_position| + described_class.extract_hashtags_with_indices(text) do |hashtag, start_position, end_position| expect(hashtag).to eq 'hashtag' expect(start_position).to eq 0 expect(end_position).to eq 8 @@ -72,7 +72,7 @@ describe Extractor do describe 'extract_cashtags_with_indices' do it 'returns []' do text = '$cashtag' - extracted = Extractor.extract_cashtags_with_indices(text) + extracted = described_class.extract_cashtags_with_indices(text) expect(extracted).to eq [] end end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index d54050f8f76..f4dd42f8451 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe FeedManager do @@ -13,7 +15,7 @@ RSpec.describe FeedManager do end describe '#key' do - subject { FeedManager.instance.key(:home, 1) } + subject { described_class.instance.key(:home, 1) } it 'returns a string' do expect(subject).to be_a String @@ -24,31 +26,32 @@ RSpec.describe FeedManager do let(:alice) { Fabricate(:account, username: 'alice') } let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } let(:jeff) { Fabricate(:account, username: 'jeff') } + let(:list) { Fabricate(:list, account: alice) } - context 'for home feed' do + context 'with home feed' do it 'returns false for followee\'s status' do status = Fabricate(:status, text: 'Hello world', account: alice) bob.follow!(alice) - expect(FeedManager.instance.filter?(:home, status, bob)).to be false + expect(described_class.instance.filter?(:home, status, bob)).to be false end it 'returns false for reblog by followee' do status = Fabricate(:status, text: 'Hello world', account: jeff) reblog = Fabricate(:status, reblog: status, account: alice) bob.follow!(alice) - expect(FeedManager.instance.filter?(:home, reblog, bob)).to be false + expect(described_class.instance.filter?(:home, reblog, bob)).to be false end it 'returns true for post from account who blocked me' do status = Fabricate(:status, text: 'Hello, World', account: alice) alice.block!(bob) - expect(FeedManager.instance.filter?(:home, status, bob)).to be true + expect(described_class.instance.filter?(:home, status, bob)).to be true end it 'returns true for post from blocked account' do status = Fabricate(:status, text: 'Hello, World', account: alice) bob.block!(alice) - expect(FeedManager.instance.filter?(:home, status, bob)).to be true + expect(described_class.instance.filter?(:home, status, bob)).to be true end it 'returns true for reblog by followee of blocked account' do @@ -56,7 +59,7 @@ RSpec.describe FeedManager do reblog = Fabricate(:status, reblog: status, account: alice) bob.follow!(alice) bob.block!(jeff) - expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true + expect(described_class.instance.filter?(:home, reblog, bob)).to be true end it 'returns true for reblog by followee of muted account' do @@ -64,7 +67,7 @@ RSpec.describe FeedManager do reblog = Fabricate(:status, reblog: status, account: alice) bob.follow!(alice) bob.mute!(jeff) - expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true + expect(described_class.instance.filter?(:home, reblog, bob)).to be true end it 'returns true for reblog by followee of someone who is blocking recipient' do @@ -72,14 +75,14 @@ RSpec.describe FeedManager do reblog = Fabricate(:status, reblog: status, account: alice) bob.follow!(alice) jeff.block!(bob) - expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true + expect(described_class.instance.filter?(:home, reblog, bob)).to be true end it 'returns true for reblog from account with reblogs disabled' do status = Fabricate(:status, text: 'Hello world', account: jeff) reblog = Fabricate(:status, reblog: status, account: alice) bob.follow!(alice, reblogs: false) - expect(FeedManager.instance.filter?(:home, reblog, bob)).to be true + expect(described_class.instance.filter?(:home, reblog, bob)).to be true end it 'returns false for reply by followee to another followee' do @@ -87,49 +90,49 @@ RSpec.describe FeedManager do reply = Fabricate(:status, text: 'Nay', thread: status, account: alice) bob.follow!(alice) bob.follow!(jeff) - expect(FeedManager.instance.filter?(:home, reply, bob)).to be false + expect(described_class.instance.filter?(:home, reply, bob)).to be false end it 'returns false for reply by followee to recipient' do status = Fabricate(:status, text: 'Hello world', account: bob) reply = Fabricate(:status, text: 'Nay', thread: status, account: alice) bob.follow!(alice) - expect(FeedManager.instance.filter?(:home, reply, bob)).to be false + expect(described_class.instance.filter?(:home, reply, bob)).to be false end it 'returns false for reply by followee to self' do status = Fabricate(:status, text: 'Hello world', account: alice) reply = Fabricate(:status, text: 'Nay', thread: status, account: alice) bob.follow!(alice) - expect(FeedManager.instance.filter?(:home, reply, bob)).to be false + expect(described_class.instance.filter?(:home, reply, bob)).to be false end it 'returns true for reply by followee to non-followed account' do status = Fabricate(:status, text: 'Hello world', account: jeff) reply = Fabricate(:status, text: 'Nay', thread: status, account: alice) bob.follow!(alice) - expect(FeedManager.instance.filter?(:home, reply, bob)).to be true + expect(described_class.instance.filter?(:home, reply, bob)).to be true end it 'returns true for the second reply by followee to a non-federated status' do reply = Fabricate(:status, text: 'Reply 1', reply: true, account: alice) second_reply = Fabricate(:status, text: 'Reply 2', thread: reply, account: alice) bob.follow!(alice) - expect(FeedManager.instance.filter?(:home, second_reply, bob)).to be true + expect(described_class.instance.filter?(:home, second_reply, bob)).to be true end it 'returns false for status by followee mentioning another account' do bob.follow!(alice) jeff.follow!(alice) status = PostStatusService.new.call(alice, text: 'Hey @jeff') - expect(FeedManager.instance.filter?(:home, status, bob)).to be false + expect(described_class.instance.filter?(:home, status, bob)).to be false end it 'returns true for status by followee mentioning blocked account' do bob.block!(jeff) bob.follow!(alice) status = PostStatusService.new.call(alice, text: 'Hey @jeff') - expect(FeedManager.instance.filter?(:home, status, bob)).to be true + expect(described_class.instance.filter?(:home, status, bob)).to be true end it 'returns true for reblog of a personally blocked domain' do @@ -137,47 +140,83 @@ RSpec.describe FeedManager do alice.follow!(jeff) status = Fabricate(:status, text: 'Hello world', account: bob) reblog = Fabricate(:status, reblog: status, account: jeff) - expect(FeedManager.instance.filter?(:home, reblog, alice)).to be true + expect(described_class.instance.filter?(:home, reblog, alice)).to be true end it 'returns true for German post when follow is set to English only' do alice.follow!(bob, languages: %w(en)) status = Fabricate(:status, text: 'Hallo Welt', account: bob, language: 'de') - expect(FeedManager.instance.filter?(:home, status, alice)).to be true + expect(described_class.instance.filter?(:home, status, alice)).to be true end it 'returns false for German post when follow is set to German' do alice.follow!(bob, languages: %w(de)) status = Fabricate(:status, text: 'Hallo Welt', account: bob, language: 'de') - expect(FeedManager.instance.filter?(:home, status, alice)).to be false + expect(described_class.instance.filter?(:home, status, alice)).to be false + end + + it 'returns true for post from followee on exclusive list' do + list.exclusive = true + alice.follow!(bob) + list.accounts << bob + allow(List).to receive(:where).and_return(list) + status = Fabricate(:status, text: 'I post a lot', account: bob) + expect(described_class.instance.filter?(:home, status, alice)).to be true + end + + it 'returns true for reblog from followee on exclusive list' do + list.exclusive = true + alice.follow!(jeff) + list.accounts << jeff + allow(List).to receive(:where).and_return(list) + status = Fabricate(:status, text: 'I post a lot', account: bob) + reblog = Fabricate(:status, reblog: status, account: jeff) + expect(described_class.instance.filter?(:home, reblog, alice)).to be true + end + + it 'returns false for post from followee on non-exclusive list' do + list.exclusive = false + alice.follow!(bob) + list.accounts << bob + status = Fabricate(:status, text: 'I post a lot', account: bob) + expect(described_class.instance.filter?(:home, status, alice)).to be false + end + + it 'returns false for reblog from followee on non-exclusive list' do + list.exclusive = false + alice.follow!(jeff) + list.accounts << jeff + status = Fabricate(:status, text: 'I post a lot', account: bob) + reblog = Fabricate(:status, reblog: status, account: jeff) + expect(described_class.instance.filter?(:home, reblog, alice)).to be false end end - context 'for mentions feed' do + context 'with mentions feed' do it 'returns true for status that mentions blocked account' do bob.block!(jeff) status = PostStatusService.new.call(alice, text: 'Hey @jeff') - expect(FeedManager.instance.filter?(:mentions, status, bob)).to be true + expect(described_class.instance.filter?(:mentions, status, bob)).to be true end it 'returns true for status that replies to a blocked account' do status = Fabricate(:status, text: 'Hello world', account: jeff) reply = Fabricate(:status, text: 'Nay', thread: status, account: alice) bob.block!(jeff) - expect(FeedManager.instance.filter?(:mentions, reply, bob)).to be true + expect(described_class.instance.filter?(:mentions, reply, bob)).to be true end it 'returns true for status by silenced account who recipient is not following' do status = Fabricate(:status, text: 'Hello world', account: alice) alice.silence! - expect(FeedManager.instance.filter?(:mentions, status, bob)).to be true + expect(described_class.instance.filter?(:mentions, status, bob)).to be true end it 'returns false for status by followed silenced account' do status = Fabricate(:status, text: 'Hello world', account: alice) alice.silence! bob.follow!(alice) - expect(FeedManager.instance.filter?(:mentions, status, bob)).to be false + expect(described_class.instance.filter?(:mentions, status, bob)).to be false end end end @@ -186,21 +225,21 @@ RSpec.describe FeedManager do it 'trims timelines if they will have more than FeedManager::MAX_ITEMS' do account = Fabricate(:account) status = Fabricate(:status) - members = FeedManager::MAX_ITEMS.times.map { |count| [count, count] } + members = Array.new(FeedManager::MAX_ITEMS) { |count| [count, count] } redis.zadd("feed:home:#{account.id}", members) - FeedManager.instance.push_to_home(account, status) + described_class.instance.push_to_home(account, status) expect(redis.zcard("feed:home:#{account.id}")).to eq FeedManager::MAX_ITEMS end - context 'reblogs' do + context 'with reblogs' do it 'saves reblogs of unseen statuses' do account = Fabricate(:account) reblogged = Fabricate(:status) reblog = Fabricate(:status, reblog: reblogged) - expect(FeedManager.instance.push_to_home(account, reblog)).to be true + expect(described_class.instance.push_to_home(account, reblog)).to be true end it 'does not save a new reblog of a recent status' do @@ -208,9 +247,9 @@ RSpec.describe FeedManager do reblogged = Fabricate(:status) reblog = Fabricate(:status, reblog: reblogged) - FeedManager.instance.push_to_home(account, reblogged) + described_class.instance.push_to_home(account, reblogged) - expect(FeedManager.instance.push_to_home(account, reblog)).to be false + expect(described_class.instance.push_to_home(account, reblog)).to be false end it 'saves a new reblog of an old status' do @@ -218,26 +257,26 @@ RSpec.describe FeedManager do reblogged = Fabricate(:status) reblog = Fabricate(:status, reblog: reblogged) - FeedManager.instance.push_to_home(account, reblogged) + described_class.instance.push_to_home(account, reblogged) # Fill the feed with intervening statuses FeedManager::REBLOG_FALLOFF.times do - FeedManager.instance.push_to_home(account, Fabricate(:status)) + described_class.instance.push_to_home(account, Fabricate(:status)) end - expect(FeedManager.instance.push_to_home(account, reblog)).to be true + expect(described_class.instance.push_to_home(account, reblog)).to be true end it 'does not save a new reblog of a recently-reblogged status' do account = Fabricate(:account) reblogged = Fabricate(:status) - reblogs = 2.times.map { Fabricate(:status, reblog: reblogged) } + reblogs = Array.new(2) { Fabricate(:status, reblog: reblogged) } # The first reblog will be accepted - FeedManager.instance.push_to_home(account, reblogs.first) + described_class.instance.push_to_home(account, reblogs.first) # The second reblog should be ignored - expect(FeedManager.instance.push_to_home(account, reblogs.last)).to be false + expect(described_class.instance.push_to_home(account, reblogs.last)).to be false end it 'saves a new reblog of a recently-reblogged status when previous reblog has been deleted' do @@ -246,48 +285,48 @@ RSpec.describe FeedManager do old_reblog = Fabricate(:status, reblog: reblogged) # The first reblog should be accepted - expect(FeedManager.instance.push_to_home(account, old_reblog)).to be true + expect(described_class.instance.push_to_home(account, old_reblog)).to be true # The first reblog should be successfully removed - expect(FeedManager.instance.unpush_from_home(account, old_reblog)).to be true + expect(described_class.instance.unpush_from_home(account, old_reblog)).to be true reblog = Fabricate(:status, reblog: reblogged) # The second reblog should be accepted - expect(FeedManager.instance.push_to_home(account, reblog)).to be true + expect(described_class.instance.push_to_home(account, reblog)).to be true end it 'does not save a new reblog of a multiply-reblogged-then-unreblogged status' do account = Fabricate(:account) reblogged = Fabricate(:status) - reblogs = 3.times.map { Fabricate(:status, reblog: reblogged) } + reblogs = Array.new(3) { Fabricate(:status, reblog: reblogged) } # Accept the reblogs - FeedManager.instance.push_to_home(account, reblogs[0]) - FeedManager.instance.push_to_home(account, reblogs[1]) + described_class.instance.push_to_home(account, reblogs[0]) + described_class.instance.push_to_home(account, reblogs[1]) # Unreblog the first one - FeedManager.instance.unpush_from_home(account, reblogs[0]) + described_class.instance.unpush_from_home(account, reblogs[0]) # The last reblog should still be ignored - expect(FeedManager.instance.push_to_home(account, reblogs.last)).to be false + expect(described_class.instance.push_to_home(account, reblogs.last)).to be false end it 'saves a new reblog of a long-ago-reblogged status' do account = Fabricate(:account) reblogged = Fabricate(:status) - reblogs = 2.times.map { Fabricate(:status, reblog: reblogged) } + reblogs = Array.new(2) { Fabricate(:status, reblog: reblogged) } # The first reblog will be accepted - FeedManager.instance.push_to_home(account, reblogs.first) + described_class.instance.push_to_home(account, reblogs.first) # Fill the feed with intervening statuses FeedManager::REBLOG_FALLOFF.times do - FeedManager.instance.push_to_home(account, Fabricate(:status)) + described_class.instance.push_to_home(account, Fabricate(:status)) end # The second reblog should also be accepted - expect(FeedManager.instance.push_to_home(account, reblogs.last)).to be true + expect(described_class.instance.push_to_home(account, reblogs.last)).to be true end end @@ -295,9 +334,9 @@ RSpec.describe FeedManager do account = Fabricate(:account) reblog = Fabricate(:status) status = Fabricate(:status, reblog: reblog) - FeedManager.instance.push_to_home(account, status) + described_class.instance.push_to_home(account, status) - expect(FeedManager.instance.push_to_home(account, reblog)).to be false + expect(described_class.instance.push_to_home(account, reblog)).to be false end end @@ -320,9 +359,9 @@ RSpec.describe FeedManager do it "does not push when the given status's reblog is already inserted" do reblog = Fabricate(:status) status = Fabricate(:status, reblog: reblog) - FeedManager.instance.push_to_list(list, status) + described_class.instance.push_to_list(list, status) - expect(FeedManager.instance.push_to_list(list, reblog)).to be false + expect(described_class.instance.push_to_list(list, reblog)).to be false end context 'when replies policy is set to no replies' do @@ -332,19 +371,19 @@ RSpec.describe FeedManager do it 'pushes statuses that are not replies' do status = Fabricate(:status, text: 'Hello world', account: bob) - expect(FeedManager.instance.push_to_list(list, status)).to be true + expect(described_class.instance.push_to_list(list, status)).to be true end it 'pushes statuses that are replies to list owner' do status = Fabricate(:status, text: 'Hello world', account: owner) reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) - expect(FeedManager.instance.push_to_list(list, reply)).to be true + expect(described_class.instance.push_to_list(list, reply)).to be true end it 'does not push replies to another member of the list' do status = Fabricate(:status, text: 'Hello world', account: alice) reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) - expect(FeedManager.instance.push_to_list(list, reply)).to be false + expect(described_class.instance.push_to_list(list, reply)).to be false end end @@ -355,25 +394,25 @@ RSpec.describe FeedManager do it 'pushes statuses that are not replies' do status = Fabricate(:status, text: 'Hello world', account: bob) - expect(FeedManager.instance.push_to_list(list, status)).to be true + expect(described_class.instance.push_to_list(list, status)).to be true end it 'pushes statuses that are replies to list owner' do status = Fabricate(:status, text: 'Hello world', account: owner) reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) - expect(FeedManager.instance.push_to_list(list, reply)).to be true + expect(described_class.instance.push_to_list(list, reply)).to be true end it 'pushes replies to another member of the list' do status = Fabricate(:status, text: 'Hello world', account: alice) reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) - expect(FeedManager.instance.push_to_list(list, reply)).to be true + expect(described_class.instance.push_to_list(list, reply)).to be true end it 'does not push replies to someone not a member of the list' do status = Fabricate(:status, text: 'Hello world', account: eve) reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) - expect(FeedManager.instance.push_to_list(list, reply)).to be false + expect(described_class.instance.push_to_list(list, reply)).to be false end end @@ -384,25 +423,25 @@ RSpec.describe FeedManager do it 'pushes statuses that are not replies' do status = Fabricate(:status, text: 'Hello world', account: bob) - expect(FeedManager.instance.push_to_list(list, status)).to be true + expect(described_class.instance.push_to_list(list, status)).to be true end it 'pushes statuses that are replies to list owner' do status = Fabricate(:status, text: 'Hello world', account: owner) reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) - expect(FeedManager.instance.push_to_list(list, reply)).to be true + expect(described_class.instance.push_to_list(list, reply)).to be true end it 'pushes replies to another member of the list' do status = Fabricate(:status, text: 'Hello world', account: alice) reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) - expect(FeedManager.instance.push_to_list(list, reply)).to be true + expect(described_class.instance.push_to_list(list, reply)).to be true end it 'pushes replies to someone not a member of the list' do status = Fabricate(:status, text: 'Hello world', account: eve) reply = Fabricate(:status, text: 'Nay', thread: status, account: bob) - expect(FeedManager.instance.push_to_list(list, reply)).to be true + expect(described_class.instance.push_to_list(list, reply)).to be true end end end @@ -412,9 +451,9 @@ RSpec.describe FeedManager do account = Fabricate(:account, id: 0) reblog = Fabricate(:status) status = Fabricate(:status, reblog: reblog) - FeedManager.instance.push_to_home(account, status) + described_class.instance.push_to_home(account, status) - FeedManager.instance.merge_into_home(account, reblog.account) + described_class.instance.merge_into_home(account, reblog.account) expect(redis.zscore('feed:home:0', reblog.id)).to be_nil end @@ -427,14 +466,14 @@ RSpec.describe FeedManager do reblogged = Fabricate(:status) status = Fabricate(:status, reblog: reblogged) - FeedManager.instance.push_to_home(receiver, reblogged) - FeedManager::REBLOG_FALLOFF.times { FeedManager.instance.push_to_home(receiver, Fabricate(:status)) } - FeedManager.instance.push_to_home(receiver, status) + described_class.instance.push_to_home(receiver, reblogged) + FeedManager::REBLOG_FALLOFF.times { described_class.instance.push_to_home(receiver, Fabricate(:status)) } + described_class.instance.push_to_home(receiver, status) # The reblogging status should show up under normal conditions. expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to include(status.id.to_s) - FeedManager.instance.unpush_from_home(receiver, status) + described_class.instance.unpush_from_home(receiver, status) # Restore original status expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to_not include(status.id.to_s) @@ -445,29 +484,29 @@ RSpec.describe FeedManager do reblogged = Fabricate(:status) status = Fabricate(:status, reblog: reblogged) - FeedManager.instance.push_to_home(receiver, status) + described_class.instance.push_to_home(receiver, status) # The reblogging status should show up under normal conditions. expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to eq [status.id.to_s] - FeedManager.instance.unpush_from_home(receiver, status) + described_class.instance.unpush_from_home(receiver, status) expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to be_empty end it 'leaves a multiply-reblogged status if another reblog was in feed' do reblogged = Fabricate(:status) - reblogs = 3.times.map { Fabricate(:status, reblog: reblogged) } + reblogs = Array.new(3) { Fabricate(:status, reblog: reblogged) } reblogs.each do |reblog| - FeedManager.instance.push_to_home(receiver, reblog) + described_class.instance.push_to_home(receiver, reblog) end # The reblogging status should show up under normal conditions. expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to eq [reblogs.first.id.to_s] reblogs[0...-1].each do |reblog| - FeedManager.instance.unpush_from_home(receiver, reblog) + described_class.instance.unpush_from_home(receiver, reblog) end expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to eq [reblogs.last.id.to_s] @@ -476,38 +515,82 @@ RSpec.describe FeedManager do it 'sends push updates' do status = Fabricate(:status) - FeedManager.instance.push_to_home(receiver, status) + described_class.instance.push_to_home(receiver, status) allow(redis).to receive_messages(publish: nil) - FeedManager.instance.unpush_from_home(receiver, status) + described_class.instance.unpush_from_home(receiver, status) deletion = Oj.dump(event: :delete, payload: status.id.to_s) expect(redis).to have_received(:publish).with("timeline:#{receiver.id}", deletion) 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(:account) { Fabricate(:account) } let(:followed_account) { Fabricate(:account) } - let(:target_account) { Fabricate(:account) } - let(:status_1) { Fabricate(:status, account: followed_account) } - let(:status_2) { Fabricate(:status, account: target_account) } - let(:status_3) { Fabricate(:status, account: followed_account, mentions: [Fabricate(:mention, account: target_account)]) } - let(:status_4) { Fabricate(:status, mentions: [Fabricate(:mention, account: target_account)]) } - let(:status_5) { Fabricate(:status, account: followed_account, reblog: status_4) } - let(:status_6) { Fabricate(:status, account: followed_account, reblog: status_2) } - let(:status_7) { Fabricate(:status, account: followed_account) } + let(:target_account) { Fabricate(:account) } + let(:status_from_followed_account_first) { Fabricate(:status, account: followed_account) } + let(:status_from_target_account) { Fabricate(:status, account: target_account) } + let(:status_from_followed_account_mentions_target_account) { Fabricate(:status, account: followed_account, mentions: [Fabricate(:mention, account: target_account)]) } + let(:status_mentions_target_account) { Fabricate(:status, mentions: [Fabricate(:mention, account: target_account)]) } + let(:status_from_followed_account_reblogs_status_mentions_target_account) { Fabricate(:status, account: followed_account, reblog: status_mentions_target_account) } + let(:status_from_followed_account_reblogs_status_from_target_account) { Fabricate(:status, account: followed_account, reblog: status_from_target_account) } + let(:status_from_followed_account_next) { Fabricate(:status, account: followed_account) } before do - [status_1, status_3, status_5, status_6, status_7].each do |status| + [ + status_from_followed_account_first, + status_from_followed_account_mentions_target_account, + status_from_followed_account_reblogs_status_mentions_target_account, + status_from_followed_account_reblogs_status_from_target_account, + status_from_followed_account_next, + ].each do |status| redis.zadd("feed:home:#{account.id}", status.id, status.id) end end it 'correctly cleans the home timeline' do - FeedManager.instance.clear_from_home(account, target_account) + described_class.instance.clear_from_home(account, target_account) - expect(redis.zrange("feed:home:#{account.id}", 0, -1)).to eq [status_1.id.to_s, status_7.id.to_s] + expect(redis.zrange("feed:home:#{account.id}", 0, -1)).to eq [status_from_followed_account_first.id.to_s, status_from_followed_account_next.id.to_s] end end end diff --git a/spec/lib/hash_object_spec.rb b/spec/lib/hash_object_spec.rb deleted file mode 100644 index ce18065209f..00000000000 --- a/spec/lib/hash_object_spec.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe HashObject do - it 'has methods corresponding to hash properties' do - expect(HashObject.new(key: 'value').key).to eq 'value' - end -end diff --git a/spec/lib/html_aware_formatter_spec.rb b/spec/lib/html_aware_formatter_spec.rb index 3d3149b8ff5..a20902d4f9e 100644 --- a/spec/lib/html_aware_formatter_spec.rb +++ b/spec/lib/html_aware_formatter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe HtmlAwareFormatter do @@ -16,7 +18,7 @@ RSpec.describe HtmlAwareFormatter do context 'when remote' do let(:local) { false } - context 'given plain text' do + context 'when given plain text' do let(:text) { 'Beep boop' } it 'keeps the plain text' do @@ -24,7 +26,7 @@ RSpec.describe HtmlAwareFormatter do end end - context 'given text containing script tags' do + context 'when given text containing script tags' do let(:text) { '' } it 'strips the scripts' do @@ -32,7 +34,7 @@ RSpec.describe HtmlAwareFormatter do end end - context 'given text containing malicious classes' do + context 'when given text containing malicious classes' do let(:text) { 'Show more' } it 'strips the malicious classes' do diff --git a/spec/lib/importer/accounts_index_importer_spec.rb b/spec/lib/importer/accounts_index_importer_spec.rb new file mode 100644 index 00000000000..73f9bce3991 --- /dev/null +++ b/spec/lib/importer/accounts_index_importer_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Importer::AccountsIndexImporter do + describe 'import!' do + let(:pool) { Concurrent::FixedThreadPool.new(5) } + let(:importer) { described_class.new(batch_size: 123, executor: pool) } + + before { Fabricate(:account) } + + it 'indexes relevant accounts' do + expect { importer.import! }.to update_index(AccountsIndex) + end + end +end diff --git a/spec/lib/importer/base_importer_spec.rb b/spec/lib/importer/base_importer_spec.rb new file mode 100644 index 00000000000..78e9a869b8b --- /dev/null +++ b/spec/lib/importer/base_importer_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Importer::BaseImporter do + describe 'import!' do + let(:pool) { Concurrent::FixedThreadPool.new(5) } + let(:importer) { described_class.new(batch_size: 123, executor: pool) } + + it 'raises an error' do + expect { importer.import! }.to raise_error(NotImplementedError) + end + end +end 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 00000000000..bc7c038a97c --- /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/importer/statuses_index_importer_spec.rb b/spec/lib/importer/statuses_index_importer_spec.rb new file mode 100644 index 00000000000..d5e1c9f2cb9 --- /dev/null +++ b/spec/lib/importer/statuses_index_importer_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Importer::StatusesIndexImporter do + describe 'import!' do + let(:pool) { Concurrent::FixedThreadPool.new(5) } + let(:importer) { described_class.new(batch_size: 123, executor: pool) } + + before { Fabricate(:status) } + + it 'indexes relevant statuses' do + expect { importer.import! }.to update_index(StatusesIndex) + end + end +end diff --git a/spec/lib/importer/tags_index_importer_spec.rb b/spec/lib/importer/tags_index_importer_spec.rb new file mode 100644 index 00000000000..348990c01e8 --- /dev/null +++ b/spec/lib/importer/tags_index_importer_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Importer::TagsIndexImporter do + describe 'import!' do + let(:pool) { Concurrent::FixedThreadPool.new(5) } + let(:importer) { described_class.new(batch_size: 123, executor: pool) } + + before { Fabricate(:tag) } + + it 'indexes relevant tags' do + expect { importer.import! }.to update_index(TagsIndex) + end + end +end diff --git a/spec/lib/link_details_extractor_spec.rb b/spec/lib/link_details_extractor_spec.rb index fcc2654744b..8c485cef2af 100644 --- a/spec/lib/link_details_extractor_spec.rb +++ b/spec/lib/link_details_extractor_spec.rb @@ -1,33 +1,33 @@ +# frozen_string_literal: true + 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 another host' do - 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 original_url + expect(subject.canonical_url).to eq 'https://example.com/dog.html' end end - context 'when canonical URL points to the same host' do - let(:html) { '' } + context 'when canonical URL points to another host' do + let(:url) { 'https://different.example.net/dog.html' } it 'ignores the canonical URLs' do - expect(subject.canonical_url).to eq 'https://foo.com/article' + expect(subject.canonical_url).to eq original_url 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 @@ -35,124 +35,267 @@ 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 'and 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 'but the first tag is invalid JSON' do + 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 let(:html) { <<~HTML } 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 new file mode 100644 index 00000000000..2c8c994712d --- /dev/null +++ b/spec/lib/mastodon/cli/accounts_spec.rb @@ -0,0 +1,1609 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'mastodon/cli/accounts' + +describe Mastodon::CLI::Accounts do + let(:cli) { described_class.new } + + # `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 '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to be true + end + end + + describe '#create' do + 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 + 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 + end + end + + context 'when required USERNAME and --email are provided' do + let(:arguments) { ['tootctl_username'] } + + context 'with USERNAME and --email only' do + let(:options) { { email: 'tootctl@example.com' } } + + it_behaves_like 'a new user with given email address and username' + + context 'with invalid --email value' 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 + .and raise_error(SystemExit) + end + end + end + + context 'with --confirmed option' do + let(:options) { { email: 'tootctl@example.com', confirmed: true } } + + 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) + + user = User.find_by(email: options[:email]) + + expect(user.confirmed?).to be(true) + end + end + + context 'with --approve option' do + let(:options) { { email: 'tootctl@example.com', approve: true } } + + before do + Form::AdminSettings.new(registrations_mode: 'approved').save + end + + 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) + + user = User.find_by(email: options[:email]) + + expect(user.approved?).to be(true) + end + end + + context 'with --role option' do + context 'when role exists' do + let(:default_role) { Fabricate(:user_role) } + let(:options) { { email: 'tootctl@example.com', role: default_role.name } } + + 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) + + role = User.find_by(email: options[:email])&.role + + expect(role.name).to eq(default_role.name) + end + end + + context 'when role does not exist' 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 + .and raise_error(SystemExit) + end + end + end + + context 'with --reattach option' do + context "when account's user is present" do + let(:options) { { email: 'tootctl_new@example.com', reattach: true } } + let(:user) { Fabricate.build(:user, email: 'tootctl@example.com') } + + before do + Fabricate(:account, username: 'tootctl_username', user: user) + 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 + 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) + + user = Account.find_local('tootctl_username')&.user + + expect(user.email).to eq(options[:email]) + end + end + end + + context "when account's user is not present" do + let(:options) { { email: 'tootctl@example.com', reattach: true } } + + before do + Fabricate(:account, username: 'tootctl_username', user: nil) + end + + it_behaves_like 'a new user with given email address and username' + end + end + end + + context 'when required --email option is not provided' do + let(:arguments) { ['tootctl_username'] } + + it 'raises a required argument missing error (Thor::RequiredArgumentMissingError)' do + expect { cli.invoke(:create, arguments) } + .to raise_error(Thor::RequiredArgumentMissingError) + end + end + end + + describe '#modify' do + 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 + .and raise_error(SystemExit) + end + end + + context 'when the given username is found' do + let(:user) { Fabricate(:user) } + 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) + + expect(user).to eq(user.reload) + end + end + + context 'with --role option' do + context 'when the given role is not found' 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 + .and raise_error(SystemExit) + end + end + + context 'when the given role is found' do + let(:default_role) { Fabricate(:user_role) } + let(:options) { { role: default_role.name } } + + it "updates the user's role to the specified role" do + cli.invoke(:modify, arguments, options) + + role = user.reload.role + + expect(role.name).to eq(default_role.name) + end + end + end + + context 'with --remove-role option' do + let(:options) { { remove_role: true } } + let(:role) { Fabricate(:user_role) } + let(:user) { Fabricate(:user, role: role) } + + it "removes the user's role successfully" do + cli.invoke(:modify, arguments, options) + + role = user.reload.role + + expect(role.name).to be_empty + end + end + + context 'with --email option' do + let(:user) { Fabricate(:user, email: 'old_email@email.com') } + 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(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(user.reload.email).to eq('old_email@email.com') + end + + context 'with --confirm option' do + let(:user) { Fabricate(:user, email: 'old_email@email.com', confirmed_at: nil) } + 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(user.reload.email).to eq(options[:email]) + end + + it "sets the user's email address as confirmed" do + cli.invoke(:modify, arguments, options) + + expect(user.reload.confirmed?).to be(true) + end + end + end + + context 'with --confirm option' do + let(:user) { Fabricate(:user, confirmed_at: nil) } + let(:options) { { confirm: true } } + + it "confirms the user's email address" do + cli.invoke(:modify, arguments, options) + + expect(user.reload.confirmed?).to be(true) + end + end + + context 'with --approve option' do + let(:user) { Fabricate(:user, approved: false) } + let(:options) { { approve: true } } + + before do + Form::AdminSettings.new(registrations_mode: 'approved').save + end + + it 'approves the user' do + expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.approved }.from(false).to(true) + end + end + + context 'with --disable option' do + let(:user) { Fabricate(:user, disabled: false) } + let(:options) { { disable: true } } + + it 'disables the user' do + expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.disabled }.from(false).to(true) + end + end + + context 'with --enable option' do + let(:user) { Fabricate(:user, disabled: true) } + let(:options) { { enable: true } } + + it 'enables the user' do + expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.disabled }.from(true).to(false) + end + end + + context 'with --reset-password option' do + let(:options) { { reset_password: true } } + + 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 + end + end + + context 'with --disable-2fa option' do + let(:user) { Fabricate(:user, otp_required_for_login: true) } + 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) + end + end + + context 'when provided data is invalid' do + let(:user) { Fabricate(:user) } + 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 + .and raise_error(SystemExit) + end + end + end + end + + describe '#delete' do + let(:account) { Fabricate(:account) } + let(:arguments) { [account.username] } + let(:options) { { email: account.user.email } } + let(:delete_account_service) { instance_double(DeleteAccountService) } + + before do + allow(DeleteAccountService).to receive(:new).and_return(delete_account_service) + allow(delete_account_service).to receive(:call) + end + + context 'when both username and --email are provided' do + 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 + .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 + .and raise_error(SystemExit) + end + end + + context 'when username is provided' do + it 'deletes the specified user successfully' do + cli.invoke(:delete, arguments) + + expect(delete_account_service).to have_received(:call).with(account, reserve_email: false).once + end + + context 'with --dry-run option' do + let(:options) { { dry_run: true } } + + it 'does not delete the specified user' do + cli.invoke(:delete, arguments, 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, 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 + .and raise_error(SystemExit) + end + end + end + + context 'when --email is provided' do + it 'deletes the specified user successfully' do + cli.invoke(:delete, nil, options) + + expect(delete_account_service).to have_received(:call).with(account, reserve_email: false).once + end + + 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 + end + end + + context 'when the given email address is not found' 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 + .and raise_error(SystemExit) + end + end + end + end + + describe '#approve' do + let(:total_users) { 10 } + + before do + Form::AdminSettings.new(registrations_mode: 'approved').save + Fabricate.times(total_users, :user) + end + + context 'with --all option' do + it 'approves all pending registrations' do + cli.invoke(:approve, nil, all: true) + + expect(User.pluck(:approved).all?(true)).to be(true) + end + end + + context 'with --number option' do + context 'when the number is positive' do + let(:options) { { number: 3 } } + + 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]) + + expect(n_earliest_pending_registrations.all?(&:approved?)).to be(true) + end + + it 'does not approve the remaining pending registrations' do + cli.invoke(:approve, nil, options) + + pending_registrations = User.order(created_at: :asc).last(total_users - options[:number]) + + expect(pending_registrations.all?(&:approved?)).to be(false) + end + end + + context 'when the number is negative' do + 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 + .and raise_error(SystemExit) + end + end + + 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) + + 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 + + context 'with username argument' do + context 'when the given username is found' do + let(:user) { User.last } + let(:arguments) { [user.account.username] } + + it 'approves the specified user successfully' do + cli.invoke(:approve, arguments) + + expect(user.reload.approved?).to be(true) + 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 such account was found' do + expect { cli.invoke(:approve, arguments) }.to output( + a_string_including('No such account') + ).to_stdout + .and raise_error(SystemExit) + end + end + end + end + + describe '#follow' do + 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 + .and raise_error(SystemExit) + end + end + + context 'when the given username is found' do + let!(:target_account) { Fabricate(:account) } + let!(:follower_bob) { Fabricate(:account, username: 'bob') } + let!(:follower_rony) { Fabricate(:account, username: 'rony') } + let!(:follower_charles) { Fabricate(:account, username: 'charles') } + let(:follow_service) { instance_double(FollowService, call: nil) } + + before do + 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(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 #{Account.local.count} accounts") + ).to_stdout + end + end + end + + describe '#unfollow' do + 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 + .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', 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) } + + before do + accounts = [follower_chris, follower_rambo, follower_ana] + 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(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 + 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 + .and raise_error(SystemExit) + end + end + + context 'when the given username is found' do + let(:account) { Fabricate(:account) } + 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 + + 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 + end + end + end + + describe '#refresh' do + 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 } + + before do + # TODO: we should be using `stub_parallelize_with_progress!` but + # this makes the assertions harder to write + allow(cli).to receive(:parallelize_with_progress).and_yield(remote_account_example_com) + .and_yield(account_example_net) + .and_return([2, nil]) + cli.options = { all: true } + 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!) + + 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 + end + + context 'with --dry-run option' do + before do + cli.options = { all: true, dry_run: true } + end + + 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!) + + 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 + end + end + end + + context 'with a list of accts' 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(:arguments) { [account_example_com_a.acct, account_example_com_b.acct] } + + before do + 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) + end + + it 'resets the avatar for the specified accounts' do + allow(account_example_com_a).to receive(:reset_avatar!) + allow(account_example_com_b).to receive(:reset_avatar!) + + cli.refresh(*arguments) + + 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 reset the avatar for unspecified accounts' do + allow(account_example_net).to receive(:reset_avatar!) + + cli.refresh(*arguments) + + expect(account_example_net).to_not have_received(:reset_avatar!) + end + + it 'resets the header for the specified accounts' do + allow(account_example_com_a).to receive(:reset_header!) + allow(account_example_com_b).to receive(:reset_header!) + + cli.refresh(*arguments) + + 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 reset the header for unspecified accounts' do + allow(account_example_net).to receive(:reset_header!) + + cli.refresh(*arguments) + + expect(account_example_net).to_not have_received(:reset_header!) + end + + context 'when an UnexpectedResponseError is raised' 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 + end + end + + context 'when a specified account is not found' 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 + .and raise_error(SystemExit) + end + end + + context 'with --dry-run option' do + before do + cli.options = { dry_run: true } + end + + 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(account_example_com_a).to_not have_received(:reset_avatar!) + expect(account_example_com_b).to_not have_received(:reset_avatar!) + end + + it 'does not refresh the header for any account' do + allow(account_example_com_a).to receive(:reset_header!) + allow(account_example_com_b).to receive(:reset_header!) + + cli.refresh(*arguments) + + expect(account_example_com_a).to_not have_received(:reset_header!) + expect(account_example_com_b).to_not have_received(:reset_header!) + end + end + 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) } + + 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]) + + cli.options = { domain: domain } + 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!) + + cli.refresh + + 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!) + 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 + .and raise_error(SystemExit) + end + end + end + + describe '#rotate' do + 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 + .and raise_error(SystemExit) + end + end + + context 'when a username is given' do + let(:account) { Fabricate(:account) } + + 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) + account.reload + + expect(account.private_key).to_not eq(old_private_key) + expect(account.public_key).to_not eq(old_public_key) + end + + it 'broadcasts the new keys for the specified account' do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) + + cli.rotate(account.username) + + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id, anything).once + 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 + 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 + + 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 + accounts.each(&:reload) + + expect(accounts.map(&:private_key)).to_not eq(old_private_keys) + expect(accounts.map(&:public_key)).to_not eq(old_public_keys) + end + + it 'broadcasts the new keys for each account' do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) + + cli.rotate + + accounts.each do |account| + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id, anything).once + end + end + end + end + + describe '#merge' do + 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 + .and raise_error(SystemExit) + end + end + + context 'when "from_account" is not found' do + let(:to_account) { Fabricate(:account, domain: 'example.com') } + let(:arguments) { ['non_existent_username@domain.com', "#{to_account.username}@#{to_account.domain}"] } + + it_behaves_like 'an account not found', 'non_existent_username@domain.com' + end + + context 'when "from_account" is a local account' do + let(:from_account) { Fabricate(:account, domain: nil, username: 'bob') } + let(:to_account) { Fabricate(:account, domain: 'example.com') } + let(:arguments) { [from_account.username, "#{to_account.username}@#{to_account.domain}"] } + + it_behaves_like 'an account not found', 'bob' + end + + context 'when "to_account" is not found' do + let(:from_account) { Fabricate(:account, domain: 'example.com') } + let(:arguments) { ["#{from_account.username}@#{from_account.domain}", 'non_existent_username'] } + + it_behaves_like 'an account not found', 'non_existent_username' + end + + context 'when "to_account" is local' do + let(:from_account) { Fabricate(:account, domain: 'example.com') } + let(:to_account) { Fabricate(:account, domain: nil, username: 'bob') } + let(:arguments) do + ["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"] + end + + it_behaves_like 'an account not found', 'bob@' + end + + context 'when "from_account" and "to_account" public keys do not match' do + let(:from_account) { instance_double(Account, username: 'bob', domain: 'example1.com', local?: false, public_key: 'from_account') } + let(:to_account) { instance_double(Account, username: 'bob', domain: 'example2.com', local?: false, public_key: 'to_account') } + let(:arguments) do + ["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"] + end + + before do + allow(Account).to receive(:find_remote).with(from_account.username, from_account.domain).and_return(from_account) + allow(Account).to receive(:find_remote).with(to_account.username, to_account.domain).and_return(to_account) + 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 + .and raise_error(SystemExit) + end + + context 'with --force option' do + let(:options) { { force: true } } + + before do + allow(to_account).to receive(:merge_with!) + allow(from_account).to receive(:destroy) + end + + it 'merges "from_account" into "to_account"' do + cli.invoke(:merge, arguments, options) + + 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 + end + + context 'when "from_account" and "to_account" public keys match' do + let(:from_account) { instance_double(Account, username: 'bob', domain: 'example1.com', local?: false, public_key: 'pub_key') } + let(:to_account) { instance_double(Account, username: 'bob', domain: 'example2.com', local?: false, public_key: 'pub_key') } + let(:arguments) do + ["#{from_account.username}@#{from_account.domain}", "#{to_account.username}@#{to_account.domain}"] + end + + before do + allow(Account).to receive(:find_remote).with(from_account.username, from_account.domain).and_return(from_account) + allow(Account).to receive(:find_remote).with(to_account.username, to_account.domain).and_return(to_account) + allow(to_account).to receive(:merge_with!) + allow(from_account).to receive(:destroy) + end + + it 'merges "from_account" into "to_account"' do + cli.invoke(:merge, arguments) + + 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(: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', 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 + before do + 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(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(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 + expect { cli.cull }.to(change { tales.reload.updated_at }) + end + + it 'displays the summary correctly' do + expect { cli.cull }.to output( + a_string_including('Visited 5 accounts, removed 2') + ).to_stdout + end + end + + context 'when a domain is specified' do + let(:domain) { 'example.net' } + + before do + 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(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(domain) }.to output( + a_string_including('Visited 2 accounts, removed 2') + ).to_stdout + end + end + + context 'when a domain is unavailable' do + shared_examples 'an unavailable domain' do + before do + 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 + + 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 5 accounts, removed 0\nThe following domains were not available during the check:\n example.net") + ).to_stdout + end + end + + context 'when a connection timeout occurs' do + before do + stub_request(:head, 'https://example.net/users/tales').to_timeout + end + + it_behaves_like 'an unavailable domain' + end + + context 'when a connection error occurs' do + before do + stub_request(:head, 'https://example.net/users/tales').to_raise(HTTP::ConnectionError) + end + + it_behaves_like 'an unavailable domain' + end + + context 'when an ssl error occurs' do + before do + stub_request(:head, 'https://example.net/users/tales').to_raise(OpenSSL::SSL::SSLError) + end + + it_behaves_like 'an unavailable domain' + end + + context 'when a private network address error occurs' do + before do + stub_request(:head, 'https://example.net/users/tales').to_raise(Mastodon::PrivateNetworkAddressError) + end + + it_behaves_like 'an unavailable domain' + end + end + end + + describe '#reset_relationships' do + 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 + .and raise_error(SystemExit) + end + end + + 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(:reset_relationships, arguments, follows: true) }.to output( + a_string_including('No such account') + ).to_stdout + .and raise_error(SystemExit) + end + end + + context 'when the given username is found' do + let(:total_relationships) { 10 } + let!(:accounts) { Fabricate.times(total_relationships, :account) } + + context 'with --follows option' do + let(:options) { { follows: true } } + + 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 + end + end + + context 'with --followers option' do + let(:options) { { followers: true } } + + before 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) + + 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 + 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 + end + end + end + end + + describe '#prune' do + 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(3, :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 + + it 'prunes all remote accounts with no interactions with local users' do + cli.prune + + prunable_account_ids = prunable_accounts.pluck(:id) + + expect(Account.where(id: prunable_account_ids).count).to eq(0) + end + + it 'displays a successful message' do + expect { cli.prune }.to output( + a_string_including("OK, pruned #{prunable_accounts.size} accounts") + ).to_stdout + end + + it 'does not prune local accounts' do + cli.prune + + expect(Account.exists?(id: local_account.id)).to be(true) + end + + it 'does not prune bot accounts' do + cli.prune + + expect(Account.exists?(id: bot_account.id)).to be(true) + end + + it 'does not prune group accounts' do + cli.prune + + expect(Account.exists?(id: group_account.id)).to be(true) + end + + it 'does not prune accounts that have been mentioned' do + cli.prune + + expect(Account.exists?(id: mentioned_account.id)).to be true + end + + context 'with --dry-run option' do + before do + cli.options = { dry_run: true } + end + + it 'does not prune any account' do + cli.prune + + 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)' do + expect { cli.prune }.to output( + a_string_including("OK, pruned #{prunable_accounts.size} accounts (DRY RUN)") + ).to_stdout + end + end + end + + describe '#migrate' do + 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 + cli.invoke(:migrate, arguments, options) + + 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 { cli.invoke(:migrate, arguments, options) }.to output( + a_string_including("OK, migrated #{source_account.acct} to #{target_account.acct}") + ).to_stdout + 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 { cli.invoke(:migrate, arguments, options) }.to output( + a_string_including('Use --replay or --target, not both') + ).to_stdout + .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 { cli.invoke(:migrate, arguments, {}) }.to output( + a_string_including('Use either --replay or --target') + ).to_stdout + .and raise_error(SystemExit) + end + end + + 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(:migrate, arguments, replay: true) }.to output( + a_string_including("No such account: #{arguments.first}") + ).to_stdout + .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 { cli.invoke(:migrate, arguments, options) }.to output( + a_string_including('The specified account has not performed any migration') + ).to_stdout + .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 { cli.invoke(:migrate, arguments, options) }.to output( + a_string_including('The specified account is not redirecting to its last migration target. Use --force if you want to replay the migration anyway') + ).to_stdout + .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 { cli.invoke(:migrate, arguments, options) }.to output( + a_string_including("The specified target account could not be found: #{options[:target]}") + ).to_stdout + .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 + cli.invoke(:migrate, arguments, options) + + 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 { cli.invoke(:migrate, arguments, options) }.to output( + a_string_including('Error: Validation failed') + ).to_stdout + .and raise_error(SystemExit) + end + end + + context 'when the specified account is redirecting to a different target account' do + before do + allow(Account).to receive(:find_local).with(source_account.username).and_return(source_account) + allow(source_account).to receive(:moved_to_account_id).and_return(-1) + end + + it 'exits with an error message' do + expect { cli.invoke(:migrate, arguments, options) }.to output( + a_string_including('The specified account is redirecting to a different target account. Use --force if you want to change the migration target') + ).to_stdout + .and raise_error(SystemExit) + end + end + + context 'with --target and --force options' do + let(:options) { { target: target_account.acct, force: true } } + + before do + target_account.aliases.create!(acct: source_account.acct) + allow(Account).to receive(:find_local).with(source_account.username).and_return(source_account) + allow(source_account).to receive(:moved_to_account_id).and_return(-1) + 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 new file mode 100644 index 00000000000..3ab42dc8cee --- /dev/null +++ b/spec/lib/mastodon/cli/cache_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'mastodon/cli/cache' + +describe Mastodon::CLI::Cache do + let(:cli) { described_class.new } + + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to be true + end + end + + describe '#clear' do + 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(Rails.cache).to have_received(:clear) + end + end + + describe '#recount' do + context 'with the `accounts` argument' do + let(:arguments) { ['accounts'] } + let(:account_stat) { Fabricate(:account_stat) } + + before do + account_stat.update(statuses_count: 123) + end + + it 're-calculates account records in the cache' do + expect { cli.invoke(:recount, arguments) }.to output( + a_string_including('OK') + ).to_stdout + + expect(account_stat.reload.statuses_count).to be_zero + end + end + + context 'with the `statuses` argument' do + let(:arguments) { ['statuses'] } + let(:status_stat) { Fabricate(:status_stat) } + + before do + status_stat.update(replies_count: 123) + end + + it 're-calculates account records in the cache' do + expect { cli.invoke(:recount, arguments) }.to output( + a_string_including('OK') + ).to_stdout + + expect(status_stat.reload.replies_count).to be_zero + end + end + + context 'with an unknown type' 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) + end + 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 new file mode 100644 index 00000000000..eb57a3cd15a --- /dev/null +++ b/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'mastodon/cli/canonical_email_blocks' + +describe Mastodon::CLI::CanonicalEmailBlocks do + let(:cli) { described_class.new } + + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to be true + end + end + + describe '#find' do + 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 + 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 + end + end + end + + describe '#remove' do + 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(CanonicalEmailBlock.matching_email('user@example.com')).to be_empty + end + end + + 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 + end + end + end +end diff --git a/spec/lib/mastodon/cli/domains_spec.rb b/spec/lib/mastodon/cli/domains_spec.rb new file mode 100644 index 00000000000..ea58845c00b --- /dev/null +++ b/spec/lib/mastodon/cli/domains_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +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 + 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 new file mode 100644 index 00000000000..333ae3f2b74 --- /dev/null +++ b/spec/lib/mastodon/cli/email_domain_blocks_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +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 + end + end +end diff --git a/spec/lib/mastodon/cli/emoji_spec.rb b/spec/lib/mastodon/cli/emoji_spec.rb new file mode 100644 index 00000000000..9b586537299 --- /dev/null +++ b/spec/lib/mastodon/cli/emoji_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +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 + end + end +end diff --git a/spec/lib/mastodon/cli/feeds_spec.rb b/spec/lib/mastodon/cli/feeds_spec.rb new file mode 100644 index 00000000000..030f0872124 --- /dev/null +++ b/spec/lib/mastodon/cli/feeds_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'mastodon/cli/feeds' + +describe Mastodon::CLI::Feeds do + let(:cli) { described_class.new } + + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to be true + end + end + + describe '#build' do + 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 + end + end + + context 'with a username' do + before { Fabricate(:account, username: 'alice') } + + let(:arguments) { ['alice'] } + + it 'regenerates feeds for the account' do + expect { cli.invoke(:build, arguments) }.to output( + a_string_including('OK') + ).to_stdout + end + end + + context 'with invalid username' 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) + end + end + end + + describe '#clear' do + 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(redis).to have_received(:del).with(key_namespace).once + end + + def key_namespace + redis.keys('feed:*') + end + end +end diff --git a/spec/lib/mastodon/cli/ip_blocks_spec.rb b/spec/lib/mastodon/cli/ip_blocks_spec.rb new file mode 100644 index 00000000000..030d9fcb19e --- /dev/null +++ b/spec/lib/mastodon/cli/ip_blocks_spec.rb @@ -0,0 +1,298 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'mastodon/cli/ip_blocks' + +describe Mastodon::CLI::IpBlocks do + let(:cli) { described_class.new } + + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to be true + end + end + + describe '#add' do + let(:ip_list) do + [ + '192.0.2.1', + '172.16.0.1', + '192.0.2.0/24', + '172.16.0.0/16', + '10.0.0.0/8', + '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + 'fe80::1', + '::1', + '2001:0db8::/32', + 'fe80::/10', + '::/128', + ] + end + let(:options) { { severity: 'no_access' } } + + 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) + 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) + 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 + end + end + + context 'with valid IP addresses' do + include_examples 'ip address blocking' + end + + context 'when a specified IP address is already blocked' do + let!(:blocked_ip) { IpBlock.create(ip: ip_list.last, severity: options[:severity]) } + + it 'skips the already blocked IP address' do + allow(IpBlock).to receive(:new).and_call_original + + cli.invoke(:add, ip_list, options) + + 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 } + .from('no_access') + .to('sign_up_requires_approval') + end + + include_examples 'ip address blocking' + end + end + + context 'when a specified IP address is invalid' do + let(:ip_list) { ['320.15.175.0', '9.5.105.255', '0.0.0.0'] } + + 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 + end + end + + context 'with --comment option' do + let(:options) { { severity: 'no_access', comment: 'Spam' } } + + include_examples 'ip address blocking' + end + + context 'with --duration option' do + let(:options) { { severity: 'no_access', duration: 10.days } } + + include_examples 'ip address blocking' + end + + context 'with "sign_up_requires_approval" severity' do + let(:options) { { severity: 'sign_up_requires_approval' } } + + include_examples 'ip address blocking' + end + + context 'with "sign_up_block" severity' do + let(:options) { { severity: 'sign_up_block' } } + + include_examples 'ip address blocking' + end + + 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) } + + before do + allow(IpBlock).to receive(:new).and_return(ip_block) + allow(ip_block).to receive(:severity=) + allow(ip_block).to receive(:expires_in=) + 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 + end + end + + context 'when no IP address is provided' do + it 'exits with an error message' do + expect { cli.add }.to output( + a_string_including('No IP(s) given') + ).to_stdout + .and raise_error(SystemExit) + end + end + end + + describe '#remove' do + context 'when removing exact matches' do + let(:ip_list) do + [ + '192.0.2.1', + '172.16.0.1', + '192.0.2.0/24', + '172.16.0.0/16', + '10.0.0.0/8', + '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + 'fe80::1', + '::1', + '2001:0db8::/32', + 'fe80::/10', + '::/128', + ] + end + + 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) + + 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 + 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: '172.16.0.0/20', severity: :no_access) } + 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) + + expect(IpBlock.where(id: [first_ip_range_block.id, second_ip_range_block.id])).to_not exist + end + + it 'does not remove other IP ranges' do + cli.invoke(:remove, arguments, options) + + expect(IpBlock.where(id: third_ip_range_block.id)).to exist + end + end + + context 'when a specified IP address is not blocked' do + let(:unblocked_ip) { '192.0.2.1' } + + 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 + end + end + + context 'when a specified IP address is invalid' do + let(:invalid_ip) { '320.15.175.0' } + + 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 + 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 + .and raise_error(SystemExit) + end + end + end + + describe '#export' do + 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) } + + context 'when --format option is set to "plain"' 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 + 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 + end + end + + context 'when --format option is set to "nginx"' 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 + 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 + 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 + end + end + end +end diff --git a/spec/lib/mastodon/cli/main_spec.rb b/spec/lib/mastodon/cli/main_spec.rb new file mode 100644 index 00000000000..e3709afe37a --- /dev/null +++ b/spec/lib/mastodon/cli/main_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'mastodon/cli/main' + +describe Mastodon::CLI::Main do + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to be true + end + end + + 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 + end + end +end diff --git a/spec/lib/mastodon/cli/maintenance_spec.rb b/spec/lib/mastodon/cli/maintenance_spec.rb new file mode 100644 index 00000000000..12cd9ca8a65 --- /dev/null +++ b/spec/lib/mastodon/cli/maintenance_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +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 + end + end +end diff --git a/spec/lib/mastodon/cli/media_spec.rb b/spec/lib/mastodon/cli/media_spec.rb new file mode 100644 index 00000000000..9543640e967 --- /dev/null +++ b/spec/lib/mastodon/cli/media_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'mastodon/cli/media' + +describe Mastodon::CLI::Media do + let(:cli) { described_class.new } + + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to be true + end + end + + describe '#remove' do + context 'with --prune-profiles and --remove-headers' do + let(:options) { { prune_profiles: true, remove_headers: true } } + + it 'warns about usage and exits' do + expect { cli.invoke(:remove, [], options) }.to output( + a_string_including('--prune-profiles and --remove-headers should not be specified simultaneously') + ).to_stdout.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 { cli.invoke(:remove, [], options) }.to output( + a_string_including('--include-follows can only be used with --prune-profiles or --remove-headers') + ).to_stdout.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 { cli.invoke(:remove, [], options) }.to output( + a_string_including('Visited 1') + ).to_stdout + + 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 { cli.invoke(:remove, [], options) }.to output( + a_string_including('Visited 1') + ).to_stdout + + 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 { cli.invoke(:remove) }.to output( + a_string_including('Removed 1') + ).to_stdout + + expect(media_attachment.reload.file).to be_blank + expect(media_attachment.reload.thumbnail).to be_blank + end + end + end + end +end diff --git a/spec/lib/mastodon/cli/preview_cards_spec.rb b/spec/lib/mastodon/cli/preview_cards_spec.rb new file mode 100644 index 00000000000..1e064ed58ed --- /dev/null +++ b/spec/lib/mastodon/cli/preview_cards_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'mastodon/cli/preview_cards' + +describe Mastodon::CLI::PreviewCards do + let(:cli) { described_class.new } + + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to be true + end + end + + describe '#remove' do + 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 { cli.invoke(:remove) }.to output( + a_string_including('Removed 2 preview cards') + .and(a_string_including('approx. 119 KB')) + ).to_stdout + end + end + + context 'with the --link option' do + let(:options) { { link: true } } + + it 'deletes thumbnails for local preview cards' do + expect { cli.invoke(:remove, [], options) }.to output( + a_string_including('Removed 1 link-type preview cards') + .and(a_string_including('approx. 59.6 KB')) + ).to_stdout + end + end + + context 'with the --days option' do + let(:options) { { days: 365 } } + + it 'deletes thumbnails for local preview cards' do + expect { cli.invoke(:remove, [], options) }.to output( + a_string_including('Removed 1 preview cards') + .and(a_string_including('approx. 59.6 KB')) + ).to_stdout + end + end + end + end +end diff --git a/spec/lib/mastodon/cli/search_spec.rb b/spec/lib/mastodon/cli/search_spec.rb new file mode 100644 index 00000000000..d5cae5bf49f --- /dev/null +++ b/spec/lib/mastodon/cli/search_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +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 + end + end +end diff --git a/spec/lib/mastodon/cli/settings_spec.rb b/spec/lib/mastodon/cli/settings_spec.rb new file mode 100644 index 00000000000..ae58e74e56d --- /dev/null +++ b/spec/lib/mastodon/cli/settings_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +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 + + describe 'subcommand "registrations"' do + let(:cli) { Mastodon::CLI::Registrations.new } + + 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 + + it 'displays success message' do + expect { cli.open }.to output( + a_string_including('OK') + ).to_stdout + 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 + + it 'displays success message' do + expect { cli.approved }.to output( + a_string_including('OK') + ).to_stdout + end + + context 'with --require-reason' do + before do + cli.options = { require_reason: true } + end + + 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) + 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 + + it 'displays success message' do + expect { cli.close }.to output( + a_string_including('OK') + ).to_stdout + end + end + end +end diff --git a/spec/lib/mastodon/cli/statuses_spec.rb b/spec/lib/mastodon/cli/statuses_spec.rb new file mode 100644 index 00000000000..38ebcd99347 --- /dev/null +++ b/spec/lib/mastodon/cli/statuses_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'mastodon/cli/statuses' + +describe Mastodon::CLI::Statuses do + let(:cli) { described_class.new } + + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to be true + end + end + + describe '#remove', use_transactional_tests: false do + context 'with small batch size' do + let(:options) { { batch_size: 0 } } + + it 'exits with error message' do + expect { cli.invoke :remove, [], options }.to output( + a_string_including('Cannot run') + ).to_stdout.and raise_error(SystemExit) + end + end + + context 'with default batch size' do + it 'removes unreferenced statuses' do + expect { cli.invoke :remove }.to output( + a_string_including('Done after') + ).to_stdout + end + end + end +end diff --git a/spec/lib/mastodon/cli/upgrade_spec.rb b/spec/lib/mastodon/cli/upgrade_spec.rb new file mode 100644 index 00000000000..9e0ab9d06e4 --- /dev/null +++ b/spec/lib/mastodon/cli/upgrade_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +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 + end + end +end diff --git a/spec/lib/mastodon/migration_warning_spec.rb b/spec/lib/mastodon/migration_warning_spec.rb new file mode 100644 index 00000000000..4adf0837ab2 --- /dev/null +++ b/spec/lib/mastodon/migration_warning_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'mastodon/migration_warning' + +describe Mastodon::MigrationWarning do + describe 'migration_duration_warning' do + before do + allow(migration).to receive(:valid_environment?).and_return(true) + allow(migration).to receive(:sleep).with(1) + end + + let(:migration) { Class.new(ActiveRecord::Migration[6.1]).extend(described_class) } + + context 'with the default message' do + it 'warns about long migrations' do + expectation = expect { migration.migration_duration_warning } + + expectation.to output(/interrupt this migration/).to_stdout + expectation.to output(/Continuing in 5/).to_stdout + end + end + + context 'with an additional message' do + it 'warns about long migrations' do + expectation = expect { migration.migration_duration_warning('Get ready for it') } + + expectation.to output(/interrupt this migration/).to_stdout + expectation.to output(/Get ready for it/).to_stdout + expectation.to output(/Continuing in 5/).to_stdout + end + end + end +end diff --git a/spec/lib/ostatus/tag_manager_spec.rb b/spec/lib/ostatus/tag_manager_spec.rb index 8104a7e791f..0e20f26c7c3 100644 --- a/spec/lib/ostatus/tag_manager_spec.rb +++ b/spec/lib/ostatus/tag_manager_spec.rb @@ -5,42 +5,42 @@ require 'rails_helper' describe OStatus::TagManager do describe '#unique_tag' do it 'returns a unique tag' do - expect(OStatus::TagManager.instance.unique_tag(Time.utc(2000), 12, 'Status')).to eq 'tag:cb6e6126.ngrok.io,2000-01-01:objectId=12:objectType=Status' + expect(described_class.instance.unique_tag(Time.utc(2000), 12, 'Status')).to eq 'tag:cb6e6126.ngrok.io,2000-01-01:objectId=12:objectType=Status' end end describe '#unique_tag_to_local_id' do it 'returns the ID part' do - expect(OStatus::TagManager.instance.unique_tag_to_local_id('tag:cb6e6126.ngrok.io,2000-01-01:objectId=12:objectType=Status', 'Status')).to eql '12' + expect(described_class.instance.unique_tag_to_local_id('tag:cb6e6126.ngrok.io,2000-01-01:objectId=12:objectType=Status', 'Status')).to eql '12' end it 'returns nil if it is not local id' do - expect(OStatus::TagManager.instance.unique_tag_to_local_id('tag:remote,2000-01-01:objectId=12:objectType=Status', 'Status')).to be_nil + expect(described_class.instance.unique_tag_to_local_id('tag:remote,2000-01-01:objectId=12:objectType=Status', 'Status')).to be_nil end it 'returns nil if it is not expected type' do - expect(OStatus::TagManager.instance.unique_tag_to_local_id('tag:cb6e6126.ngrok.io,2000-01-01:objectId=12:objectType=Block', 'Status')).to be_nil + expect(described_class.instance.unique_tag_to_local_id('tag:cb6e6126.ngrok.io,2000-01-01:objectId=12:objectType=Block', 'Status')).to be_nil end it 'returns nil if it does not have object ID' do - expect(OStatus::TagManager.instance.unique_tag_to_local_id('tag:cb6e6126.ngrok.io,2000-01-01:objectType=Status', 'Status')).to be_nil + expect(described_class.instance.unique_tag_to_local_id('tag:cb6e6126.ngrok.io,2000-01-01:objectType=Status', 'Status')).to be_nil end end describe '#local_id?' do it 'returns true for a local ID' do - expect(OStatus::TagManager.instance.local_id?('tag:cb6e6126.ngrok.io;objectId=12:objectType=Status')).to be true + expect(described_class.instance.local_id?('tag:cb6e6126.ngrok.io;objectId=12:objectType=Status')).to be true end it 'returns false for a foreign ID' do - expect(OStatus::TagManager.instance.local_id?('tag:foreign.tld;objectId=12:objectType=Status')).to be false + expect(described_class.instance.local_id?('tag:foreign.tld;objectId=12:objectType=Status')).to be false end end describe '#uri_for' do - subject { OStatus::TagManager.instance.uri_for(target) } + subject { described_class.instance.uri_for(target) } - context 'comment object' do + context 'with comment object' do let(:target) { Fabricate(:status, created_at: '2000-01-01T00:00:00Z', reply: true) } it 'returns the unique tag for status' do @@ -49,7 +49,7 @@ describe OStatus::TagManager do end end - context 'note object' do + context 'with note object' do let(:target) { Fabricate(:status, created_at: '2000-01-01T00:00:00Z', reply: false, thread: nil) } it 'returns the unique tag for status' do @@ -58,7 +58,7 @@ describe OStatus::TagManager do end end - context 'person object' do + context 'when person object' do let(:target) { Fabricate(:account, username: 'alice') } it 'returns the URL for account' do diff --git a/spec/lib/plain_text_formatter_spec.rb b/spec/lib/plain_text_formatter_spec.rb index 0e5f39031d5..80b3c331a6b 100644 --- a/spec/lib/plain_text_formatter_spec.rb +++ b/spec/lib/plain_text_formatter_spec.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe PlainTextFormatter do describe '#to_s' do subject { described_class.new(status.text, status.local?).to_s } - context 'given a post with local status' do + context 'when status is local' do let(:status) { Fabricate(:status, text: '

a text by a nerd who uses an HTML tag in text

', uri: nil) } it 'returns the raw text' do @@ -12,12 +14,63 @@ RSpec.describe PlainTextFormatter do end end - context 'given a post with remote status' do + context 'when status is remote' do let(:remote_account) { Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/') } - let(:status) { Fabricate(:status, account: remote_account, text: '

Hello

') } - it 'returns tag-stripped text' do - expect(subject).to eq 'Hello' + context 'when text contains inline HTML tags' do + let(:status) { Fabricate(:status, account: remote_account, text: 'Lorem ipsum') } + + it 'strips the tags' do + expect(subject).to eq 'Lorem ipsum' + end + end + + context 'when text contains

tags' do + let(:status) { Fabricate(:status, account: remote_account, text: '

Lorem

ipsum

') } + + it 'inserts a newline' do + expect(subject).to eq "Lorem\nipsum" + end + end + + context 'when text contains a single
tag' do + let(:status) { Fabricate(:status, account: remote_account, text: 'Lorem
ipsum') } + + it 'inserts a newline' do + expect(subject).to eq "Lorem\nipsum" + end + end + + context 'when text contains consecutive
tag' do + let(:status) { Fabricate(:status, account: remote_account, text: 'Lorem


ipsum') } + + it 'inserts a single newline' do + expect(subject).to eq "Lorem\nipsum" + end + end + + context 'when text contains HTML entity' do + let(:status) { Fabricate(:status, account: remote_account, text: 'Lorem & ipsum ❤') } + + it 'unescapes the entity' do + expect(subject).to eq 'Lorem & ipsum ❤' + end + end + + context 'when text contains ipsum') } + + it 'strips the tag and its contents' do + expect(subject).to eq 'Lorem ipsum' + end + end + + context 'when text contains an HTML comment tags' do + let(:status) { Fabricate(:status, account: remote_account, text: 'Lorem ipsum') } + + it 'strips the comment' do + expect(subject).to eq 'Lorem ipsum' + end end end end diff --git a/spec/lib/request_pool_spec.rb b/spec/lib/request_pool_spec.rb index 63dc9c5dd26..f179e6ca94a 100644 --- a/spec/lib/request_pool_spec.rb +++ b/spec/lib/request_pool_spec.rb @@ -33,7 +33,7 @@ describe RequestPool do subject - threads = 20.times.map do |_i| + threads = Array.new(20) do |_i| Thread.new do 20.times do subject.with('http://example.com') do |http_client| @@ -48,16 +48,25 @@ describe RequestPool do expect(subject.size).to be > 1 end - it 'closes idle connections' do - stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!') - - subject.with('http://example.com') do |http_client| - http_client.get('/').flush + context 'with an idle connection' do + before do + stub_const('RequestPool::MAX_IDLE_TIME', 1) # Lower idle time limit to 1 seconds + stub_const('RequestPool::REAPER_FREQUENCY', 0.1) # Run reaper every 0.1 seconds + stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!') end - expect(subject.size).to eq 1 - sleep RequestPool::MAX_IDLE_TIME + 30 + 1 - expect(subject.size).to eq 0 + it 'closes the connections' do + subject.with('http://example.com') do |http_client| + http_client.get('/').flush + end + + expect { reaper_observes_idle_timeout }.to change(subject, :size).from(1).to(0) + end + + def reaper_observes_idle_timeout + # One full idle period and 2 reaper cycles more + sleep RequestPool::MAX_IDLE_TIME + (RequestPool::REAPER_FREQUENCY * 2) + end end end end diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb index 25fe9ed379e..f0861376b99 100644 --- a/spec/lib/request_spec.rb +++ b/spec/lib/request_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' require 'securerandom' describe Request do - subject { Request.new(:get, 'http://example.com') } + subject { described_class.new(:get, 'http://example.com') } describe '#headers' do it 'returns user agent' do @@ -48,7 +48,7 @@ describe Request do end it 'executes a HTTP request when the first address is private' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:4860:4860::8844)) allow(resolver).to receive(:timeouts=).and_return(nil) @@ -83,7 +83,7 @@ describe Request do end it 'raises Mastodon::ValidationError' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:db8::face)) allow(resolver).to receive(:timeouts=).and_return(nil) diff --git a/spec/lib/sanitize_config_spec.rb b/spec/lib/sanitize_config_spec.rb index c9543ceb0c1..550ad1c52b0 100644 --- a/spec/lib/sanitize_config_spec.rb +++ b/spec/lib/sanitize_config_spec.rb @@ -6,24 +6,16 @@ describe Sanitize::Config do describe '::MASTODON_STRICT' do subject { Sanitize::Config::MASTODON_STRICT } - it 'converts h1 to p' do - expect(Sanitize.fragment('

Foo

', subject)).to eq '

Foo

' + it 'converts h1 to p strong' do + expect(Sanitize.fragment('

Foo

', subject)).to eq '

Foo

' end - it 'converts ul to p' do - expect(Sanitize.fragment('

Check out:

', subject)).to eq '

Check out:

Foo
Bar

' + it 'keeps ul' do + expect(Sanitize.fragment('

Check out:

', subject)).to eq '

Check out:

' end - it 'converts p inside ul' do - expect(Sanitize.fragment('', subject)).to eq '

Foo
Bar
Baz

' - end - - it 'converts ul inside ul' do - expect(Sanitize.fragment('', subject)).to eq '

Foo
Bar
Baz

' - end - - it 'keep links in lists' do - expect(Sanitize.fragment('

Check out:

', subject)).to eq '

Check out:

joinmastodon.org
Bar

' + it 'keeps start and reversed attributes of ol' do + expect(Sanitize.fragment('

Check out:

  1. Foo
  2. Bar
', subject)).to eq '

Check out:

  1. Foo
  2. Bar
' end it 'removes a without href' do @@ -45,5 +37,21 @@ describe Sanitize::Config do it 'keeps a with href' do expect(Sanitize.fragment('Test', subject)).to eq 'Test' end + + it 'keeps a with translate="no"' do + expect(Sanitize.fragment('Test', subject)).to eq 'Test' + end + + it 'removes "translate" attribute with invalid value' do + expect(Sanitize.fragment('Test', subject)).to eq 'Test' + end + + it 'removes a with unparsable href' do + expect(Sanitize.fragment('Test', subject)).to eq 'Test' + end + + it 'keeps a with supported scheme and no host' do + expect(Sanitize.fragment('Test', subject)).to eq 'Test' + end end end diff --git a/spec/lib/scope_transformer_spec.rb b/spec/lib/scope_transformer_spec.rb index e5a992144d0..8a9c7cf9672 100644 --- a/spec/lib/scope_transformer_spec.rb +++ b/spec/lib/scope_transformer_spec.rb @@ -20,67 +20,67 @@ describe ScopeTransformer do end end - context 'for scope "read"' do + context 'with scope "read"' do let(:input) { 'read' } it_behaves_like 'a scope', nil, 'all', 'read' end - context 'for scope "write"' do + context 'with scope "write"' do let(:input) { 'write' } it_behaves_like 'a scope', nil, 'all', 'write' end - context 'for scope "follow"' do + context 'with scope "follow"' do let(:input) { 'follow' } it_behaves_like 'a scope', nil, 'follow', 'read/write' end - context 'for scope "crypto"' do + context 'with scope "crypto"' do let(:input) { 'crypto' } it_behaves_like 'a scope', nil, 'crypto', 'read/write' end - context 'for scope "push"' do + context 'with scope "push"' do let(:input) { 'push' } it_behaves_like 'a scope', nil, 'push', 'read/write' end - context 'for scope "admin:read"' do + context 'with scope "admin:read"' do let(:input) { 'admin:read' } it_behaves_like 'a scope', 'admin', 'all', 'read' end - context 'for scope "admin:write"' do + context 'with scope "admin:write"' do let(:input) { 'admin:write' } it_behaves_like 'a scope', 'admin', 'all', 'write' end - context 'for scope "admin:read:accounts"' do + context 'with scope "admin:read:accounts"' do let(:input) { 'admin:read:accounts' } it_behaves_like 'a scope', 'admin', 'accounts', 'read' end - context 'for scope "admin:write:accounts"' do + context 'with scope "admin:write:accounts"' do let(:input) { 'admin:write:accounts' } it_behaves_like 'a scope', 'admin', 'accounts', 'write' end - context 'for scope "read:accounts"' do + context 'with scope "read:accounts"' do let(:input) { 'read:accounts' } it_behaves_like 'a scope', nil, 'accounts', 'read' end - context 'for scope "write:accounts"' do + context 'with scope "write:accounts"' do let(:input) { 'write:accounts' } it_behaves_like 'a scope', nil, 'accounts', 'write' diff --git a/spec/lib/search_query_parser_spec.rb b/spec/lib/search_query_parser_spec.rb new file mode 100644 index 00000000000..66b0e8f9e23 --- /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 new file mode 100644 index 00000000000..5817e3d1d20 --- /dev/null +++ b/spec/lib/search_query_transformer_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe SearchQueryTransformer do + subject { described_class.new.apply(parser, current_account: account) } + + let(:account) { Fabricate(:account) } + let(:parser) { SearchQueryParser.new.parse(query) } + + 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/settings/extend_spec.rb b/spec/lib/settings/extend_spec.rb deleted file mode 100644 index ea623137b4a..00000000000 --- a/spec/lib/settings/extend_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Settings::Extend do - class User - include Settings::Extend - end - - describe '#settings' do - it 'sets @settings as an instance of Settings::ScopedSettings' do - user = Fabricate(:user) - expect(user.settings).to be_a Settings::ScopedSettings - end - end -end diff --git a/spec/lib/settings/scoped_settings_spec.rb b/spec/lib/settings/scoped_settings_spec.rb deleted file mode 100644 index 7566685b4a5..00000000000 --- a/spec/lib/settings/scoped_settings_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Settings::ScopedSettings do - let(:object) { Fabricate(:user) } - let(:scoped_setting) { described_class.new(object) } - let(:val) { 'whatever' } - let(:methods) { %i(auto_play_gif default_sensitive unfollow_modal boost_modal delete_modal reduce_motion system_font_ui noindex theme) } - - describe '.initialize' do - it 'sets @object' do - scoped_setting = described_class.new(object) - expect(scoped_setting.instance_variable_get(:@object)).to be object - end - end - - describe '#method_missing' do - it 'sets scoped_setting.method_name = val' do - methods.each do |key| - scoped_setting.send("#{key}=", val) - expect(scoped_setting.send(key)).to eq val - end - end - end - - describe '#[]= and #[]' do - it 'sets [key] = val' do - methods.each do |key| - scoped_setting[key] = val - expect(scoped_setting[key]).to eq val - end - end - end -end diff --git a/spec/lib/status_cache_hydrator_spec.rb b/spec/lib/status_cache_hydrator_spec.rb index 5c78de7116e..5b80ccb9708 100644 --- a/spec/lib/status_cache_hydrator_spec.rb +++ b/spec/lib/status_cache_hydrator_spec.rb @@ -44,7 +44,7 @@ describe StatusCacheHydrator do let(:reblog) { Fabricate(:status) } let(:status) { Fabricate(:status, reblog: reblog) } - context 'that has been favourited' do + context 'when it has been favourited' do before do FavouriteService.new.call(account, reblog) end @@ -54,7 +54,7 @@ describe StatusCacheHydrator do end end - context 'that has been reblogged' do + context 'when it has been reblogged' do before do ReblogService.new.call(account, reblog) end @@ -64,7 +64,7 @@ describe StatusCacheHydrator do end end - context 'that has been pinned' do + context 'when it has been pinned' do let(:reblog) { Fabricate(:status, account: account) } before do @@ -76,7 +76,7 @@ describe StatusCacheHydrator do end end - context 'that has been followed tags' do + context 'when it has been followed tags' do let(:followed_tag) { Fabricate(:tag) } before do @@ -90,7 +90,7 @@ describe StatusCacheHydrator do end end - context 'that has a poll authored by the user' do + context 'when it has a poll authored by the user' do let(:poll) { Fabricate(:poll, account: account) } let(:reblog) { Fabricate(:status, poll: poll, account: account) } @@ -99,7 +99,7 @@ describe StatusCacheHydrator do end end - context 'that has been voted in' do + context 'when it has been voted in' do let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) } let(:reblog) { Fabricate(:status, poll: poll) } @@ -112,7 +112,7 @@ describe StatusCacheHydrator do end end - context 'that matches account filters' do + context 'when it matches account filters' do let(:reblog) { Fabricate(:status, text: 'this toot is about that banned word') } before do diff --git a/spec/lib/status_filter_spec.rb b/spec/lib/status_filter_spec.rb index 08519bc5903..c994ad419fa 100644 --- a/spec/lib/status_filter_spec.rb +++ b/spec/lib/status_filter_spec.rb @@ -7,7 +7,7 @@ describe StatusFilter do let(:status) { Fabricate(:status) } context 'without an account' do - subject { described_class.new(status, nil) } + subject(:filter) { described_class.new(status, nil) } context 'when there are no connections' do it { is_expected.to_not be_filtered } @@ -22,16 +22,16 @@ describe StatusFilter do end context 'when status policy does not allow show' do - before do - expect_any_instance_of(StatusPolicy).to receive(:show?).and_return(false) - end + it 'filters the status' do + allow_any_instance_of(StatusPolicy).to receive(:show?).and_return(false) - it { is_expected.to be_filtered } + expect(filter).to be_filtered + end end end context 'with real account' do - subject { described_class.new(status, account) } + subject(:filter) { described_class.new(status, account) } let(:account) { Fabricate(:account) } @@ -73,11 +73,11 @@ describe StatusFilter do end context 'when status policy does not allow show' do - before do - expect_any_instance_of(StatusPolicy).to receive(:show?).and_return(false) - end + it 'filters the status' do + allow_any_instance_of(StatusPolicy).to receive(:show?).and_return(false) - it { is_expected.to be_filtered } + expect(filter).to be_filtered + end end end end diff --git a/spec/lib/status_finder_spec.rb b/spec/lib/status_finder_spec.rb index 61483f4bfe9..53f5039af97 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/status_reach_finder_spec.rb b/spec/lib/status_reach_finder_spec.rb index 785ce28a0ea..7181717dc11 100644 --- a/spec/lib/status_reach_finder_spec.rb +++ b/spec/lib/status_reach_finder_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' describe StatusReachFinder do describe '#inboxes' do - context 'for a local status' do + context 'with a local status' do subject { described_class.new(status) } let(:parent_status) { nil } @@ -71,10 +71,8 @@ describe StatusReachFinder do bob.statuses.create!(thread: status, text: 'Hoge') end - context do - it 'includes the inbox of the replier' do - expect(subject.inboxes).to include 'https://foo.bar/inbox' - end + it 'includes the inbox of the replier' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' end context 'when status is not public' do @@ -90,10 +88,8 @@ describe StatusReachFinder do let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } let(:parent_status) { Fabricate(:status, account: bob) } - context do - it 'includes the inbox of the replied-to account' do - expect(subject.inboxes).to include 'https://foo.bar/inbox' - end + it 'includes the inbox of the replied-to account' do + expect(subject.inboxes).to include 'https://foo.bar/inbox' end context 'when status is not public and replied-to account is not mentioned' do diff --git a/spec/lib/suspicious_sign_in_detector_spec.rb b/spec/lib/suspicious_sign_in_detector_spec.rb index 05aadfd8fae..9e64aff08a1 100644 --- a/spec/lib/suspicious_sign_in_detector_spec.rb +++ b/spec/lib/suspicious_sign_in_detector_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe SuspiciousSignInDetector do @@ -5,7 +7,7 @@ RSpec.describe SuspiciousSignInDetector do subject { described_class.new(user).suspicious?(request) } let(:user) { Fabricate(:user, current_sign_in_at: 1.day.ago) } - let(:request) { double(remote_ip: remote_ip) } + let(:request) { instance_double(ActionDispatch::Request, remote_ip: remote_ip) } let(:remote_ip) { nil } context 'when user has 2FA enabled' do diff --git a/spec/lib/tag_manager_spec.rb b/spec/lib/tag_manager_spec.rb index d2bb24c0f13..38203a55f70 100644 --- a/spec/lib/tag_manager_spec.rb +++ b/spec/lib/tag_manager_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe TagManager do @@ -14,15 +16,15 @@ RSpec.describe TagManager do end it 'returns true for nil' do - expect(TagManager.instance.local_domain?(nil)).to be true + expect(described_class.instance.local_domain?(nil)).to be true end it 'returns true if the slash-stripped string equals to local domain' do - expect(TagManager.instance.local_domain?('DoMaIn.Example.com/')).to be true + expect(described_class.instance.local_domain?('DoMaIn.Example.com/')).to be true end it 'returns false for irrelevant string' do - expect(TagManager.instance.local_domain?('DoMaIn.Example.com!')).to be false + expect(described_class.instance.local_domain?('DoMaIn.Example.com!')).to be false end end @@ -39,25 +41,25 @@ RSpec.describe TagManager do end it 'returns true for nil' do - expect(TagManager.instance.web_domain?(nil)).to be true + expect(described_class.instance.web_domain?(nil)).to be true end it 'returns true if the slash-stripped string equals to web domain' do - expect(TagManager.instance.web_domain?('DoMaIn.Example.com/')).to be true + expect(described_class.instance.web_domain?('DoMaIn.Example.com/')).to be true end it 'returns false for string with irrelevant characters' do - expect(TagManager.instance.web_domain?('DoMaIn.Example.com!')).to be false + expect(described_class.instance.web_domain?('DoMaIn.Example.com!')).to be false end end describe '#normalize_domain' do it 'returns nil if the given parameter is nil' do - expect(TagManager.instance.normalize_domain(nil)).to be_nil + expect(described_class.instance.normalize_domain(nil)).to be_nil end it 'returns normalized domain' do - expect(TagManager.instance.normalize_domain('DoMaIn.Example.com/')).to eq 'domain.example.com' + expect(described_class.instance.normalize_domain('DoMaIn.Example.com/')).to eq 'domain.example.com' end end @@ -70,17 +72,17 @@ RSpec.describe TagManager do it 'returns true if the normalized string with port is local URL' do Rails.configuration.x.web_domain = 'domain.example.com:42' - expect(TagManager.instance.local_url?('https://DoMaIn.Example.com:42/')).to be true + expect(described_class.instance.local_url?('https://DoMaIn.Example.com:42/')).to be true end it 'returns true if the normalized string without port is local URL' do Rails.configuration.x.web_domain = 'domain.example.com' - expect(TagManager.instance.local_url?('https://DoMaIn.Example.com/')).to be true + expect(described_class.instance.local_url?('https://DoMaIn.Example.com/')).to be true end it 'returns false for string with irrelevant characters' do Rails.configuration.x.web_domain = 'domain.example.com' - expect(TagManager.instance.local_url?('https://domain.example.net/')).to be false + expect(described_class.instance.local_url?('https://domain.example.net/')).to be false end end end diff --git a/spec/lib/text_formatter_spec.rb b/spec/lib/text_formatter_spec.rb index 04ae4e02c05..8b922c018b0 100644 --- a/spec/lib/text_formatter_spec.rb +++ b/spec/lib/text_formatter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe TextFormatter do @@ -6,7 +8,7 @@ RSpec.describe TextFormatter do let(:preloaded_accounts) { nil } - context 'given text containing plain text' do + context 'when given text containing plain text' do let(:text) { 'text' } it 'paragraphizes the text' do @@ -14,7 +16,7 @@ RSpec.describe TextFormatter do end end - context 'given text containing line feeds' do + context 'when given text containing line feeds' do let(:text) { "line\nfeed" } it 'removes line feeds' do @@ -22,7 +24,7 @@ RSpec.describe TextFormatter do end end - context 'given text containing linkable mentions' do + context 'when given text containing linkable mentions' do let(:preloaded_accounts) { [Fabricate(:account, username: 'alice')] } let(:text) { '@alice' } @@ -31,7 +33,7 @@ RSpec.describe TextFormatter do end end - context 'given text containing unlinkable mentions' do + context 'when given text containing unlinkable mentions' do let(:preloaded_accounts) { [] } let(:text) { '@alice' } @@ -40,7 +42,7 @@ RSpec.describe TextFormatter do end end - context 'given a stand-alone medium URL' do + context 'when given a stand-alone medium URL' do let(:text) { 'https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4' } it 'matches the full URL' do @@ -48,7 +50,7 @@ RSpec.describe TextFormatter do end end - context 'given a stand-alone google URL' do + context 'when given a stand-alone google URL' do let(:text) { 'http://google.com' } it 'matches the full URL' do @@ -56,7 +58,7 @@ RSpec.describe TextFormatter do end end - context 'given a stand-alone URL with a newer TLD' do + context 'when given a stand-alone URL with a newer TLD' do let(:text) { 'http://example.gay' } it 'matches the full URL' do @@ -64,7 +66,7 @@ RSpec.describe TextFormatter do end end - context 'given a stand-alone IDN URL' do + context 'when given a stand-alone IDN URL' do let(:text) { 'https://nic.みんな/' } it 'matches the full URL' do @@ -76,7 +78,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL with a trailing period' do + context 'when given a URL with a trailing period' do let(:text) { 'http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona. ' } it 'matches the full URL but not the period' do @@ -84,7 +86,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL enclosed with parentheses' do + context 'when given a URL enclosed with parentheses' do let(:text) { '(http://google.com/)' } it 'matches the full URL but not the parentheses' do @@ -92,7 +94,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL with a trailing exclamation point' do + context 'when given a URL with a trailing exclamation point' do let(:text) { 'http://www.google.com!' } it 'matches the full URL but not the exclamation point' do @@ -100,7 +102,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL with a trailing single quote' do + context 'when given a URL with a trailing single quote' do let(:text) { "http://www.google.com'" } it 'matches the full URL but not the single quote' do @@ -108,7 +110,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL with a trailing angle bracket' do + context 'when given a URL with a trailing angle bracket' do let(:text) { 'http://www.google.com>' } it 'matches the full URL but not the angle bracket' do @@ -116,7 +118,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL with a query string' do + context 'when given a URL with a query string' do context 'with escaped unicode character' do let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' } @@ -150,7 +152,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL with parentheses in it' do + context 'when given a URL with parentheses in it' do let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' } it 'matches the full URL' do @@ -158,7 +160,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL in quotation marks' do + context 'when given a URL in quotation marks' do let(:text) { '"https://example.com/"' } it 'does not match the quotation marks' do @@ -166,7 +168,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL in angle brackets' do + context 'when given a URL in angle brackets' do let(:text) { '' } it 'does not match the angle brackets' do @@ -174,7 +176,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL with Japanese path string' do + context 'when given a URL with Japanese path string' do let(:text) { 'https://ja.wikipedia.org/wiki/日本' } it 'matches the full URL' do @@ -182,7 +184,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL with Korean path string' do + context 'when given a URL with Korean path string' do let(:text) { 'https://ko.wikipedia.org/wiki/대한민국' } it 'matches the full URL' do @@ -190,7 +192,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL with a full-width space' do + context 'when given a URL with a full-width space' do let(:text) { 'https://example.com/ abc123' } it 'does not match the full-width space' do @@ -198,7 +200,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL in Japanese quotation marks' do + context 'when given a URL in Japanese quotation marks' do let(:text) { '「[https://example.org/」' } it 'does not match the quotation marks' do @@ -206,7 +208,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL with Simplified Chinese path string' do + context 'when given a URL with Simplified Chinese path string' do let(:text) { 'https://baike.baidu.com/item/中华人民共和国' } it 'matches the full URL' do @@ -214,7 +216,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL with Traditional Chinese path string' do + context 'when given a URL with Traditional Chinese path string' do let(:text) { 'https://zh.wikipedia.org/wiki/臺灣' } it 'matches the full URL' do @@ -222,7 +224,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL containing unsafe code (XSS attack, visible part)' do + context 'when given a URL containing unsafe code (XSS attack, visible part)' do let(:text) { 'http://example.com/bb' } it 'does not include the HTML in the URL' do @@ -234,7 +236,7 @@ RSpec.describe TextFormatter do end end - context 'given a URL containing unsafe code (XSS attack, invisible part)' do + context 'when given a URL containing unsafe code (XSS attack, invisible part)' do let(:text) { 'http://example.com/blahblahblahblah/a' } it 'does not include the HTML in the URL' do @@ -246,7 +248,7 @@ RSpec.describe TextFormatter do end end - context 'given text containing HTML code (script tag)' do + context 'when given text containing HTML code (script tag)' do let(:text) { '' } it 'escapes the HTML' do @@ -254,7 +256,7 @@ RSpec.describe TextFormatter do end end - context 'given text containing HTML (XSS attack)' do + context 'when given text containing HTML (XSS attack)' do let(:text) { %q{} } it 'escapes the HTML' do @@ -262,7 +264,7 @@ RSpec.describe TextFormatter do end end - context 'given an invalid URL' do + context 'when given an invalid URL' do let(:text) { 'http://www\.google\.com' } it 'outputs the raw URL' do @@ -270,7 +272,7 @@ RSpec.describe TextFormatter do end end - context 'given text containing a hashtag' do + context 'when given text containing a hashtag' do let(:text) { '#hashtag' } it 'creates a hashtag link' do @@ -278,7 +280,7 @@ RSpec.describe TextFormatter do end end - context 'given text containing a hashtag with Unicode chars' do + context 'when given text containing a hashtag with Unicode chars' do let(:text) { '#hashtagタグ' } it 'creates a hashtag link' do @@ -286,7 +288,7 @@ RSpec.describe TextFormatter do end end - context 'given text with a stand-alone xmpp: URI' do + context 'when given text with a stand-alone xmpp: URI' do let(:text) { 'xmpp:user@instance.com' } it 'matches the full URI' do @@ -294,7 +296,7 @@ RSpec.describe TextFormatter do end end - context 'given text with an xmpp: URI with a query-string' do + context 'when given text with an xmpp: URI with a query-string' do let(:text) { 'please join xmpp:muc@instance.com?join right now' } it 'matches the full URI' do @@ -302,7 +304,7 @@ RSpec.describe TextFormatter do end end - context 'given text containing a magnet: URI' do + context 'when given text containing a magnet: URI' do let(:text) { 'wikipedia gives this example of a magnet uri: magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a' } it 'matches the full URI' do diff --git a/spec/lib/translation_service/deepl_spec.rb b/spec/lib/translation_service/deepl_spec.rb new file mode 100644 index 00000000000..5a1d0f094a8 --- /dev/null +++ b/spec/lib/translation_service/deepl_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TranslationService::DeepL do + subject(:service) { described_class.new(plan, 'my-api-key') } + + 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"}]' + ) + end + + describe '#translate' do + it 'returns translation with specified source language' do + stub_request(:post, 'https://api.deepl.com/v2/translate') + .with(body: 'text=Hasta+la+vista&source_lang=ES&target_lang=en&tag_handling=html') + .to_return(body: '{"translations":[{"detected_source_language":"ES","text":"See you soon"}]}') + + translations = service.translate(['Hasta la vista'], 'es', 'en') + expect(translations.size).to eq 1 + + translation = translations.first + expect(translation.detected_source_language).to eq 'es' + expect(translation.provider).to eq 'DeepL.com' + expect(translation.text).to eq 'See you soon' + end + + it 'returns translation with auto-detected source language' do + stub_request(:post, 'https://api.deepl.com/v2/translate') + .with(body: 'text=Guten+Tag&source_lang&target_lang=en&tag_handling=html') + .to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good morning"}]}') + + translations = service.translate(['Guten Tag'], nil, 'en') + expect(translations.size).to eq 1 + + translation = translations.first + expect(translation.detected_source_language).to eq 'de' + expect(translation.provider).to eq 'DeepL.com' + expect(translation.text).to eq 'Good morning' + end + + it 'returns translation of multiple texts' do + stub_request(:post, 'https://api.deepl.com/v2/translate') + .with(body: 'text=Guten+Morgen&text=Gute+Nacht&source_lang=DE&target_lang=en&tag_handling=html') + .to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good morning"},{"detected_source_language":"DE","text":"Good night"}]}') + + translations = service.translate(['Guten Morgen', 'Gute Nacht'], 'de', 'en') + expect(translations.size).to eq 2 + + expect(translations.first.text).to eq 'Good morning' + expect(translations.last.text).to eq 'Good night' + end + end + + describe '#languages' do + it 'returns source languages' do + expect(service.languages.keys).to eq [nil, 'en', 'uk'] + end + + it 'returns target languages for each source language' do + expect(service.languages['en']).to eq %w(pt en-GB zh) + expect(service.languages['uk']).to eq %w(en pt en-GB zh) + end + + it 'returns target languages for auto-detection' do + expect(service.languages[nil]).to eq %w(en pt en-GB zh) + end + end + + describe '#request' do + before do + stub_request(:any, //) + # rubocop:disable Lint/EmptyBlock + service.send(:request, :get, '/v2/languages') { |res| } + # rubocop:enable Lint/EmptyBlock + 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 + 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 + end + end +end diff --git a/spec/lib/translation_service/libre_translate_spec.rb b/spec/lib/translation_service/libre_translate_spec.rb new file mode 100644 index 00000000000..90966a8ebf6 --- /dev/null +++ b/spec/lib/translation_service/libre_translate_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TranslationService::LibreTranslate do + subject(:service) { described_class.new('https://libretranslate.example.com', 'my-api-key') } + + before do + stub_request(:get, 'https://libretranslate.example.com/languages').to_return( + body: '[{"code": "en","name": "English","targets": ["de","en","es"]},{"code": "da","name": "Danish","targets": ["en","pt"]}]' + ) + end + + describe '#languages' do + subject(:languages) { service.languages } + + it 'returns source languages' do + expect(languages.keys).to eq ['en', 'da', nil] + end + + it 'returns target languages for each source language' do + expect(languages['en']).to eq %w(de es) + expect(languages['da']).to eq %w(en pt) + end + + it 'returns target languages for auto-detected language' do + expect(languages[nil]).to eq %w(de en es pt) + end + end + + describe '#translate' do + it 'returns translation with specified source language' do + stub_request(:post, 'https://libretranslate.example.com/translate') + .with(body: '{"q":["Hasta la vista"],"source":"es","target":"en","format":"html","api_key":"my-api-key"}') + .to_return(body: '{"translatedText": ["See you"]}') + + translations = service.translate(['Hasta la vista'], 'es', 'en') + expect(translations.size).to eq 1 + + translation = translations.first + expect(translation.detected_source_language).to be 'es' + expect(translation.provider).to eq 'LibreTranslate' + expect(translation.text).to eq 'See you' + end + + it 'returns translation with auto-detected source language' do + stub_request(:post, 'https://libretranslate.example.com/translate') + .with(body: '{"q":["Guten Morgen"],"source":"auto","target":"en","format":"html","api_key":"my-api-key"}') + .to_return(body: '{"detectedLanguage": [{"confidence": 92, "language": "de"}], "translatedText": ["Good morning"]}') + + translations = service.translate(['Guten Morgen'], nil, 'en') + expect(translations.size).to eq 1 + + translation = translations.first + expect(translation.detected_source_language).to eq 'de' + expect(translation.provider).to eq 'LibreTranslate' + expect(translation.text).to eq 'Good morning' + end + + it 'returns translation of multiple texts' do + stub_request(:post, 'https://libretranslate.example.com/translate') + .with(body: '{"q":["Guten Morgen","Gute Nacht"],"source":"de","target":"en","format":"html","api_key":"my-api-key"}') + .to_return(body: '{"translatedText": ["Good morning", "Good night"]}') + + translations = service.translate(['Guten Morgen', 'Gute Nacht'], 'de', 'en') + expect(translations.size).to eq 2 + + expect(translations.first.text).to eq 'Good morning' + expect(translations.last.text).to eq 'Good night' + end + end +end diff --git a/spec/lib/user_settings_decorator_spec.rb b/spec/lib/user_settings_decorator_spec.rb deleted file mode 100644 index 3b9b7ee2b2e..00000000000 --- a/spec/lib/user_settings_decorator_spec.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe UserSettingsDecorator do - describe 'update' do - let(:user) { Fabricate(:user) } - let(:settings) { described_class.new(user) } - - it 'updates the user settings value for email notifications' do - values = { 'notification_emails' => { 'follow' => '1' } } - - settings.update(values) - expect(user.settings['notification_emails']['follow']).to be true - end - - it 'updates the user settings value for interactions' do - values = { 'interactions' => { 'must_be_follower' => '0' } } - - settings.update(values) - expect(user.settings['interactions']['must_be_follower']).to be false - end - - it 'updates the user settings value for privacy' do - values = { 'setting_default_privacy' => 'public' } - - settings.update(values) - expect(user.settings['default_privacy']).to eq 'public' - end - - it 'updates the user settings value for sensitive' do - values = { 'setting_default_sensitive' => '1' } - - settings.update(values) - expect(user.settings['default_sensitive']).to be true - end - - it 'updates the user settings value for unfollow modal' do - values = { 'setting_unfollow_modal' => '0' } - - settings.update(values) - expect(user.settings['unfollow_modal']).to be false - end - - it 'updates the user settings value for boost modal' do - values = { 'setting_boost_modal' => '1' } - - settings.update(values) - expect(user.settings['boost_modal']).to be true - end - - it 'updates the user settings value for delete toot modal' do - values = { 'setting_delete_modal' => '0' } - - settings.update(values) - expect(user.settings['delete_modal']).to be false - end - - it 'updates the user settings value for gif auto play' do - values = { 'setting_auto_play_gif' => '0' } - - settings.update(values) - expect(user.settings['auto_play_gif']).to be false - end - - it 'updates the user settings value for system font in UI' do - values = { 'setting_system_font_ui' => '0' } - - settings.update(values) - expect(user.settings['system_font_ui']).to be false - end - - it 'decoerces setting values before applying' do - values = { - 'setting_delete_modal' => 'false', - 'setting_boost_modal' => 'true', - } - - settings.update(values) - expect(user.settings['delete_modal']).to be false - expect(user.settings['boost_modal']).to be true - end - end -end diff --git a/spec/lib/vacuum/access_tokens_vacuum_spec.rb b/spec/lib/vacuum/access_tokens_vacuum_spec.rb index 0244c344926..54760c41bd4 100644 --- a/spec/lib/vacuum/access_tokens_vacuum_spec.rb +++ b/spec/lib/vacuum/access_tokens_vacuum_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Vacuum::AccessTokensVacuum do @@ -5,9 +7,11 @@ RSpec.describe Vacuum::AccessTokensVacuum do describe '#perform' do let!(:revoked_access_token) { Fabricate(:access_token, revoked_at: 1.minute.ago) } + let!(:expired_access_token) { Fabricate(:access_token, expires_in: 59.minutes.to_i, created_at: 1.hour.ago) } let!(:active_access_token) { Fabricate(:access_token) } let!(:revoked_access_grant) { Fabricate(:access_grant, revoked_at: 1.minute.ago) } + let!(:expired_access_grant) { Fabricate(:access_grant, expires_in: 59.minutes.to_i, created_at: 1.hour.ago) } let!(:active_access_grant) { Fabricate(:access_grant) } before do @@ -18,10 +22,18 @@ RSpec.describe Vacuum::AccessTokensVacuum do expect { revoked_access_token.reload }.to raise_error ActiveRecord::RecordNotFound end + it 'deletes expired access tokens' do + expect { expired_access_token.reload }.to raise_error ActiveRecord::RecordNotFound + end + it 'deletes revoked access grants' do expect { revoked_access_grant.reload }.to raise_error ActiveRecord::RecordNotFound end + it 'deletes expired access grants' do + expect { expired_access_grant.reload }.to raise_error ActiveRecord::RecordNotFound + end + it 'does not delete active access tokens' do expect { active_access_token.reload }.to_not raise_error end diff --git a/spec/lib/vacuum/applications_vacuum_spec.rb b/spec/lib/vacuum/applications_vacuum_spec.rb new file mode 100644 index 00000000000..57a222aafc8 --- /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) } + + let!(:active_access_token) { Fabricate(:access_token, application: app_with_token) } + let!(:active_access_grant) { Fabricate(:access_grant, application: app_with_grant) } + let!(:user) { Fabricate(:user, created_by_application: app_with_signup) } + + before do + 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/backups_vacuum_spec.rb b/spec/lib/vacuum/backups_vacuum_spec.rb index c505a3e1ae6..867dbe4020c 100644 --- a/spec/lib/vacuum/backups_vacuum_spec.rb +++ b/spec/lib/vacuum/backups_vacuum_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Vacuum::BackupsVacuum do diff --git a/spec/lib/vacuum/feeds_vacuum_spec.rb b/spec/lib/vacuum/feeds_vacuum_spec.rb index 0aec26740f3..ede1e3c3609 100644 --- a/spec/lib/vacuum/feeds_vacuum_spec.rb +++ b/spec/lib/vacuum/feeds_vacuum_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Vacuum::FeedsVacuum do diff --git a/spec/lib/vacuum/imports_vacuum_spec.rb b/spec/lib/vacuum/imports_vacuum_spec.rb new file mode 100644 index 00000000000..1e0abc5e011 --- /dev/null +++ b/spec/lib/vacuum/imports_vacuum_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Vacuum::ImportsVacuum do + subject { described_class.new } + + let!(:old_unconfirmed) { Fabricate(:bulk_import, state: :unconfirmed, created_at: 2.days.ago) } + let!(:new_unconfirmed) { Fabricate(:bulk_import, state: :unconfirmed, created_at: 10.seconds.ago) } + let!(:recent_ongoing) { Fabricate(:bulk_import, state: :in_progress, created_at: 20.minutes.ago) } + let!(:recent_finished) { Fabricate(:bulk_import, state: :finished, created_at: 1.day.ago) } + let!(:old_finished) { Fabricate(:bulk_import, state: :finished, created_at: 2.months.ago) } + + 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)) + end + end +end diff --git a/spec/lib/vacuum/media_attachments_vacuum_spec.rb b/spec/lib/vacuum/media_attachments_vacuum_spec.rb index afcb6f878a9..3c17ecb0003 100644 --- a/spec/lib/vacuum/media_attachments_vacuum_spec.rb +++ b/spec/lib/vacuum/media_attachments_vacuum_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Vacuum::MediaAttachmentsVacuum do diff --git a/spec/lib/vacuum/preview_cards_vacuum_spec.rb b/spec/lib/vacuum/preview_cards_vacuum_spec.rb index 524f4c92748..c1b7f7e9c50 100644 --- a/spec/lib/vacuum/preview_cards_vacuum_spec.rb +++ b/spec/lib/vacuum/preview_cards_vacuum_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Vacuum::PreviewCardsVacuum do diff --git a/spec/lib/vacuum/statuses_vacuum_spec.rb b/spec/lib/vacuum/statuses_vacuum_spec.rb index 9583376b72d..d5c01395064 100644 --- a/spec/lib/vacuum/statuses_vacuum_spec.rb +++ b/spec/lib/vacuum/statuses_vacuum_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Vacuum::StatusesVacuum do diff --git a/spec/lib/vacuum/system_keys_vacuum_spec.rb b/spec/lib/vacuum/system_keys_vacuum_spec.rb index 565892f0252..84cae30411e 100644 --- a/spec/lib/vacuum/system_keys_vacuum_spec.rb +++ b/spec/lib/vacuum/system_keys_vacuum_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Vacuum::SystemKeysVacuum do diff --git a/spec/lib/webfinger_resource_spec.rb b/spec/lib/webfinger_resource_spec.rb index ee007da70a5..558a318927f 100644 --- a/spec/lib/webfinger_resource_spec.rb +++ b/spec/lib/webfinger_resource_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe WebfingerResource do @@ -15,7 +17,7 @@ describe WebfingerResource do resource = 'https://example.com/users/alice/other' expect do - WebfingerResource.new(resource).username + described_class.new(resource).username end.to raise_error(ActiveRecord::RecordNotFound) end @@ -25,41 +27,42 @@ 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) expect do - WebfingerResource.new(resource).username + described_class.new(resource).username end.to raise_error(ActiveRecord::RecordNotFound) + expect(recognized).to have_received(:[]).exactly(3).times end it 'raises with a string that doesnt start with URL' do resource = 'website for http://example.com/users/alice/other' expect do - WebfingerResource.new(resource).username + described_class.new(resource).username end.to raise_error(WebfingerResource::InvalidRequest) end it 'finds the username in a valid https route' do resource = 'https://example.com/users/alice' - result = WebfingerResource.new(resource).username + result = described_class.new(resource).username expect(result).to eq 'alice' end it 'finds the username in a mixed case http route' do resource = 'HTTp://exAMPLe.com/users/alice' - result = WebfingerResource.new(resource).username + result = described_class.new(resource).username expect(result).to eq 'alice' end it 'finds the username in a valid http route' do resource = 'http://example.com/users/alice' - result = WebfingerResource.new(resource).username + result = described_class.new(resource).username expect(result).to eq 'alice' end end @@ -69,7 +72,7 @@ describe WebfingerResource do resource = 'user@remote-host.com' expect do - WebfingerResource.new(resource).username + described_class.new(resource).username end.to raise_error(ActiveRecord::RecordNotFound) end @@ -77,7 +80,7 @@ describe WebfingerResource do Rails.configuration.x.local_domain = 'example.com' resource = 'alice@example.com' - result = WebfingerResource.new(resource).username + result = described_class.new(resource).username expect(result).to eq 'alice' end @@ -85,7 +88,7 @@ describe WebfingerResource do Rails.configuration.x.web_domain = 'example.com' resource = 'alice@example.com' - result = WebfingerResource.new(resource).username + result = described_class.new(resource).username expect(result).to eq 'alice' end end @@ -95,7 +98,7 @@ describe WebfingerResource do resource = 'acct:user@remote-host.com' expect do - WebfingerResource.new(resource).username + described_class.new(resource).username end.to raise_error(ActiveRecord::RecordNotFound) end @@ -103,7 +106,7 @@ describe WebfingerResource do resource = 'acct:user@remote-host@remote-hostess.remote.local@remote' expect do - WebfingerResource.new(resource).username + described_class.new(resource).username end.to raise_error(ActiveRecord::RecordNotFound) end @@ -111,7 +114,7 @@ describe WebfingerResource do Rails.configuration.x.local_domain = 'example.com' resource = 'acct:alice@example.com' - result = WebfingerResource.new(resource).username + result = described_class.new(resource).username expect(result).to eq 'alice' end @@ -119,7 +122,7 @@ describe WebfingerResource do Rails.configuration.x.web_domain = 'example.com' resource = 'acct:alice@example.com' - result = WebfingerResource.new(resource).username + result = described_class.new(resource).username expect(result).to eq 'alice' end end @@ -129,7 +132,7 @@ describe WebfingerResource do resource = 'df/:dfkj' expect do - WebfingerResource.new(resource).username + described_class.new(resource).username end.to raise_error(WebfingerResource::InvalidRequest) end end diff --git a/spec/lib/webhooks/payload_renderer_spec.rb b/spec/lib/webhooks/payload_renderer_spec.rb new file mode 100644 index 00000000000..074847c74c5 --- /dev/null +++ b/spec/lib/webhooks/payload_renderer_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Webhooks::PayloadRenderer do + subject(:renderer) { described_class.new(json) } + + let(:event) { Webhooks::EventPresenter.new(type, object) } + let(:payload) { ActiveModelSerializers::SerializableResource.new(event, serializer: REST::Admin::WebhookEventSerializer, scope: nil, scope_name: :current_user).as_json } + let(:json) { Oj.dump(payload) } + + describe '#render' do + context 'when event is account.approved' do + let(:type) { 'account.approved' } + let(:object) { Fabricate(:account, display_name: 'Foo"') } + + it 'renders event-related variables into template' do + expect(renderer.render('foo={{event}}')).to eq 'foo=account.approved' + end + + it 'renders event-specific variables into template' do + expect(renderer.render('foo={{object.username}}')).to eq "foo=#{object.username}" + end + + it 'escapes values for use in JSON' do + expect(renderer.render('foo={{object.account.display_name}}')).to eq 'foo=Foo\\"' + end + end + end +end diff --git a/spec/locales/i18n_spec.rb b/spec/locales/i18n_spec.rb new file mode 100644 index 00000000000..cfce8e2234b --- /dev/null +++ b/spec/locales/i18n_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'I18n' do + describe 'Pluralizing locale translations' do + subject { I18n.t('generic.validation_errors', count: 1) } + + context 'with the `en` locale which has `one` and `other` plural values' do + around do |example| + I18n.with_locale(:en) do + example.run + end + end + + it 'translates to `en` correctly and without error' do + expect { subject }.to_not raise_error + expect(subject).to match(/the error below/) + end + end + + context 'with the `my` locale which has only `other` plural value' do + around do |example| + I18n.with_locale(:my) do + example.run + end + end + + it 'translates to `my` correctly and without error' do + expect { subject }.to_not raise_error + expect(subject).to match(/1/) + end + end + end +end diff --git a/spec/mailers/admin_mailer_spec.rb b/spec/mailers/admin_mailer_spec.rb index 29fb586a343..423dce88ab0 100644 --- a/spec/mailers/admin_mailer_spec.rb +++ b/spec/mailers/admin_mailer_spec.rb @@ -2,12 +2,12 @@ require 'rails_helper' -RSpec.describe AdminMailer, type: :mailer do +RSpec.describe AdminMailer do describe '.new_report' 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) @@ -23,4 +23,108 @@ RSpec.describe AdminMailer, type: :mailer 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") end end + + describe '.new_appeal' do + let(:appeal) { Fabricate(:appeal) } + let(:recipient) { Fabricate(:account, username: 'Kurt') } + 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}" + end + end + + describe '.new_pending_account' do + let(:recipient) { Fabricate(:account, username: 'Barklums') } + let(:user) { Fabricate(: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.' + end + end + + describe '.new_trends' do + let(:recipient) { Fabricate(:account, username: 'Snurf') } + let(:links) { [] } + let(:statuses) { [] } + let(:tags) { [] } + let(:mail) { described_class.with(recipient: recipient).new_trends(links, tags, statuses) } + + 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'] + end + + it 'renders the body' do + expect(mail.body.encoded).to match 'The following items need a review before they can be displayed publicly' + 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 Mastodon versions are available for 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 '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 headers', :aggregate_failures do + expect(mail.subject).to eq('Critical Mastodon updates are available for cb6e6126.ngrok.io!') + expect(mail.to).to eq [recipient.user_email] + expect(mail.from).to eq ['notifications@localhost'] + + expect(mail['Importance'].value).to eq 'high' + expect(mail['Priority'].value).to eq 'urgent' + expect(mail['X-Priority'].value).to eq '1' + end + + it 'renders the body' do + expect(mail.body.encoded).to match 'New critical versions of Mastodon have been released, you may want to update as soon as possible!' + end + end end diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index 6746871a3de..78a497c06bd 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -1,33 +1,44 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe NotificationMailer, type: :mailer do - let(:receiver) { Fabricate(:user) } +RSpec.describe NotificationMailer do + 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 = %i(de en).sample - receiver.update!(locale: locale) - expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: locale)) + shared_examples 'headers' do |type, thread| + it 'renders the to and from headers' do + expect(mail[:to].value).to eq "#{receiver.account.username} <#{receiver.email}>" + expect(mail.from).to eq ['notifications@localhost'] 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)) + it 'renders the list headers' do + expect(mail['List-ID'].value).to eq "<#{type}.alice.cb6e6126.ngrok.io>" + expect(mail['List-Unsubscribe'].value).to match(%r{}) + expect(mail['List-Unsubscribe'].value).to match("&type=#{type}") + expect(mail['List-Unsubscribe-Post'].value).to eq 'List-Unsubscribe=One-Click' + end + + if thread + it 'renders the thread headers' do + expect(mail['In-Reply-To'].value).to match(//) + expect(mail['References'].value).to match(//) + end end end describe 'mention' do let(:mention) { Mention.create!(account: receiver.account, status: foreign_status) } - let(:mail) { NotificationMailer.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 'headers', 'mention', true - it 'renders the headers' do + it 'renders the subject' do expect(mail.subject).to eq('You were mentioned by bob') - expect(mail.to).to eq([receiver.email]) end it 'renders the body' do @@ -38,13 +49,14 @@ RSpec.describe NotificationMailer, type: :mailer do describe 'follow' do let(:follow) { sender.follow!(receiver.account) } - let(:mail) { NotificationMailer.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 'headers', 'follow', false - it 'renders the headers' do + it 'renders the subject' do expect(mail.subject).to eq('bob is now following you') - expect(mail.to).to eq([receiver.email]) end it 'renders the body' do @@ -54,30 +66,32 @@ RSpec.describe NotificationMailer, type: :mailer do describe 'favourite' do let(:favourite) { Favourite.create!(account: sender, status: own_status) } - let(:mail) { NotificationMailer.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 'headers', 'favourite', true - it 'renders the headers' do - expect(mail.subject).to eq('bob favourited your post') - expect(mail.to).to eq([receiver.email]) + it 'renders the subject' do + expect(mail.subject).to eq('bob favorited your post') end it 'renders the body' do - expect(mail.body.encoded).to match('Your post was favourited by bob') + expect(mail.body.encoded).to match('Your post was favorited by bob') expect(mail.body.encoded).to include 'The body of the own status' end end describe 'reblog' do let(:reblog) { Status.create!(account: sender, reblog: own_status) } - let(:mail) { NotificationMailer.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 'headers', 'reblog', true - it 'renders the headers' do + it 'renders the subject' do expect(mail.subject).to eq('bob boosted your post') - expect(mail.to).to eq([receiver.email]) end it 'renders the body' do @@ -88,17 +102,24 @@ RSpec.describe NotificationMailer, type: :mailer do describe 'follow_request' do let(:follow_request) { Fabricate(:follow_request, account: sender, target_account: receiver.account) } - let(:mail) { NotificationMailer.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 'headers', 'follow_request', false - it 'renders the headers' do + it 'renders the subject' do expect(mail.subject).to eq('Pending follower: bob') - expect(mail.to).to eq([receiver.email]) end it 'renders the body' do expect(mail.body.encoded).to match('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 0ec9e9882ce..bc8f0193b9c 100644 --- a/spec/mailers/previews/admin_mailer_preview.rb +++ b/spec/mailers/previews/admin_mailer_preview.rb @@ -1,18 +1,20 @@ +# frozen_string_literal: true + # 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) + 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.new_trends(Account.first, PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3)) + 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_appeal def new_appeal - AdminMailer.new_appeal(Account.first, Appeal.first) + AdminMailer.with(recipient: Account.first).new_appeal(Appeal.first) end end diff --git a/spec/mailers/previews/notification_mailer_preview.rb b/spec/mailers/previews/notification_mailer_preview.rb index e31445c365c..a63c20c27c5 100644 --- a/spec/mailers/previews/notification_mailer_preview.rb +++ b/spec/mailers/previews/notification_mailer_preview.rb @@ -1,38 +1,44 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/notification_mailer 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 95712e6cf42..098c9cd901f 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Preview all emails at http://localhost:3000/rails/mailers/user_mailer class UserMailerPreview < ActionMailer::Preview diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 9c22f60f1dc..5affa66e078 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -2,24 +2,11 @@ require 'rails_helper' -describe UserMailer, type: :mailer do +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 = I18n.available_locales.sample - 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 - let(:mail) { UserMailer.confirmation_instructions(receiver, 'spec') } + let(:mail) { described_class.confirmation_instructions(receiver, 'spec') } it 'renders confirmation instructions' do receiver.update!(locale: nil) @@ -34,7 +21,7 @@ describe UserMailer, type: :mailer do end describe 'reconfirmation_instructions' do - let(:mail) { UserMailer.confirmation_instructions(receiver, 'spec') } + let(:mail) { described_class.confirmation_instructions(receiver, 'spec') } it 'renders reconfirmation instructions' do receiver.update!(email: 'new-email@example.com', locale: nil) @@ -48,7 +35,7 @@ describe UserMailer, type: :mailer do end describe 'reset_password_instructions' do - let(:mail) { UserMailer.reset_password_instructions(receiver, 'spec') } + let(:mail) { described_class.reset_password_instructions(receiver, 'spec') } it 'renders reset password instructions' do receiver.update!(locale: nil) @@ -61,7 +48,7 @@ describe UserMailer, type: :mailer do end describe 'password_change' do - let(:mail) { UserMailer.password_change(receiver) } + let(:mail) { described_class.password_change(receiver) } it 'renders password change notification' do receiver.update!(locale: nil) @@ -73,7 +60,7 @@ describe UserMailer, type: :mailer do end describe 'email_changed' do - let(:mail) { UserMailer.email_changed(receiver) } + let(:mail) { described_class.email_changed(receiver) } it 'renders email change notification' do receiver.update!(locale: nil) @@ -86,7 +73,7 @@ describe UserMailer, type: :mailer do describe 'warning' do let(:strike) { Fabricate(:account_warning, target_account: receiver.account, text: 'dont worry its just the testsuite', action: 'suspend') } - let(:mail) { UserMailer.warning(receiver, strike) } + let(:mail) { described_class.warning(receiver, strike) } it 'renders warning notification' do receiver.update!(locale: nil) @@ -94,4 +81,107 @@ describe UserMailer, type: :mailer do expect(mail.body.encoded).to include strike.text end end + + 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') + end + + include_examples 'localized subject', + 'devise.mailer.webauthn_credential.deleted.subject' + end + + 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 } + let(:mail) { described_class.suspicious_sign_in(receiver, ip, agent, timestamp) } + + 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') + end + + include_examples 'localized subject', + 'user_mailer.suspicious_sign_in.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') + end + end + + 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') + end + end + + 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') + end + end + + 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') + end + end + + 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') + end + end + + 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') + end + end + + 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') + end + end + + 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') + end + end end diff --git a/spec/models/account/field_spec.rb b/spec/models/account/field_spec.rb index 36e1a8595af..22593bb218b 100644 --- a/spec/models/account/field_spec.rb +++ b/spec/models/account/field_spec.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Account::Field, type: :model do +RSpec.describe Account::Field do describe '#verified?' do subject { described_class.new(account, 'name' => 'Foo', 'value' => 'Bar', 'verified_at' => verified_at) } - let(:account) { double('Account', local?: true) } + let(:account) { instance_double(Account, local?: true) } context 'when verified_at is set' do let(:verified_at) { Time.now.utc.iso8601 } @@ -26,7 +28,7 @@ RSpec.describe Account::Field, type: :model do describe '#mark_verified!' do subject { described_class.new(account, original_hash) } - let(:account) { double('Account', local?: true) } + let(:account) { instance_double(Account, local?: true) } let(:original_hash) { { 'name' => 'Foo', 'value' => 'Bar' } } before do @@ -45,12 +47,12 @@ RSpec.describe Account::Field, type: :model do describe '#verifiable?' do subject { described_class.new(account, 'name' => 'Foo', 'value' => value) } - let(:account) { double('Account', local?: local) } + let(:account) { instance_double(Account, local?: local) } - context 'for local accounts' do + context 'with local accounts' do let(:local) { true } - context 'for a URL with misleading authentication' do + context 'with a URL with misleading authentication' do let(:value) { 'https://spacex.com @h.43z.one' } it 'returns false' do @@ -58,7 +60,7 @@ RSpec.describe Account::Field, type: :model do end end - context 'for a URL' do + context 'with a URL' do let(:value) { 'https://example.com' } it 'returns true' do @@ -66,7 +68,7 @@ RSpec.describe Account::Field, type: :model do end end - context 'for an IDN URL' do + context 'with an IDN URL' do let(:value) { 'https://twitter.com∕dougallj∕status∕1590357240443437057.ê.cc/twitter.html' } it 'returns false' do @@ -74,7 +76,7 @@ RSpec.describe Account::Field, type: :model do end end - context 'for a URL with a non-normalized path' do + context 'with a URL with a non-normalized path' do let(:value) { 'https://github.com/octocatxxxxxxxx/../mastodon' } it 'returns false' do @@ -82,7 +84,7 @@ RSpec.describe Account::Field, type: :model do end end - context 'for text that is not a URL' do + context 'with text that is not a URL' do let(:value) { 'Hello world' } it 'returns false' do @@ -90,7 +92,7 @@ RSpec.describe Account::Field, type: :model do end end - context 'for text that contains a URL' do + context 'with text that contains a URL' do let(:value) { 'Hello https://example.com world' } it 'returns false' do @@ -98,7 +100,7 @@ RSpec.describe Account::Field, type: :model do end end - context 'for text which is blank' do + context 'with text which is blank' do let(:value) { '' } it 'returns false' do @@ -107,10 +109,10 @@ RSpec.describe Account::Field, type: :model do end end - context 'for remote accounts' do + context 'with remote accounts' do let(:local) { false } - context 'for a link' do + context 'with a link' do let(:value) { 'patreon.com/mastodon' } it 'returns true' do @@ -118,7 +120,7 @@ RSpec.describe Account::Field, type: :model do end end - context 'for a link with misleading authentication' do + context 'with a link with misleading authentication' do let(:value) { 'google.com' } it 'returns false' do @@ -126,7 +128,7 @@ RSpec.describe Account::Field, type: :model do end end - context 'for HTML that has more than just a link' do + context 'with HTML that has more than just a link' do let(:value) { 'google.com @h.43z.one' } it 'returns false' do @@ -134,7 +136,7 @@ RSpec.describe Account::Field, type: :model do end end - context 'for a link with different visible text' do + context 'with a link with different visible text' do let(:value) { 'https://example.com/foo' } it 'returns false' do @@ -142,7 +144,7 @@ RSpec.describe Account::Field, type: :model do end end - context 'for text that is a URL but is not linked' do + context 'with text that is a URL but is not linked' do let(:value) { 'https://example.com/foo' } it 'returns false' do @@ -150,7 +152,7 @@ RSpec.describe Account::Field, type: :model do end end - context 'for text which is blank' do + context 'with text which is blank' do let(:value) { '' } it 'returns false' do diff --git a/spec/models/account_alias_spec.rb b/spec/models/account_alias_spec.rb deleted file mode 100644 index c48b804b27c..00000000000 --- a/spec/models/account_alias_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe AccountAlias, type: :model do -end diff --git a/spec/models/account_conversation_spec.rb b/spec/models/account_conversation_spec.rb index 70a76281ef8..4e8727ca395 100644 --- a/spec/models/account_conversation_spec.rb +++ b/spec/models/account_conversation_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe AccountConversation, type: :model do +RSpec.describe AccountConversation do let!(:alice) { Fabricate(:account, username: 'alice') } let!(:bob) { Fabricate(:account, username: 'bob') } let!(:mark) { Fabricate(:account, username: 'mark') } @@ -10,7 +12,7 @@ RSpec.describe AccountConversation, type: :model do status = Fabricate(:status, account: alice, visibility: :direct) status.mentions.create(account: bob) - conversation = AccountConversation.add_status(alice, status) + conversation = described_class.add_status(alice, status) expect(conversation.participant_accounts).to include(bob) expect(conversation.last_status).to eq status @@ -19,12 +21,12 @@ RSpec.describe AccountConversation, type: :model do it 'appends to old record when there is a match' do last_status = Fabricate(:status, account: alice, visibility: :direct) - conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) + conversation = described_class.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status) status.mentions.create(account: alice) - new_conversation = AccountConversation.add_status(alice, status) + new_conversation = described_class.add_status(alice, status) expect(new_conversation.id).to eq conversation.id expect(new_conversation.participant_accounts).to include(bob) @@ -34,13 +36,13 @@ RSpec.describe AccountConversation, type: :model do it 'creates new record when new participants are added' do last_status = Fabricate(:status, account: alice, visibility: :direct) - conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) + conversation = described_class.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status) status.mentions.create(account: alice) status.mentions.create(account: mark) - new_conversation = AccountConversation.add_status(alice, status) + new_conversation = described_class.add_status(alice, status) expect(new_conversation.id).to_not eq conversation.id expect(new_conversation.participant_accounts).to include(bob, mark) @@ -53,7 +55,7 @@ RSpec.describe AccountConversation, type: :model do it 'updates last status to a previous value' do last_status = Fabricate(:status, account: alice, visibility: :direct) status = Fabricate(:status, account: alice, visibility: :direct) - conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [status.id, last_status.id]) + conversation = described_class.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [status.id, last_status.id]) last_status.mentions.create(account: bob) last_status.destroy! conversation.reload @@ -63,10 +65,10 @@ RSpec.describe AccountConversation, type: :model do it 'removes the record if no other statuses are referenced' do last_status = Fabricate(:status, account: alice, visibility: :direct) - conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) + conversation = described_class.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) last_status.mentions.create(account: bob) last_status.destroy! - expect(AccountConversation.where(id: conversation.id).count).to eq 0 + expect(described_class.where(id: conversation.id).count).to eq 0 end end end diff --git a/spec/models/account_deletion_request_spec.rb b/spec/models/account_deletion_request_spec.rb deleted file mode 100644 index afaecbe2289..00000000000 --- a/spec/models/account_deletion_request_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe AccountDeletionRequest, type: :model do -end diff --git a/spec/models/account_domain_block_spec.rb b/spec/models/account_domain_block_spec.rb index a170abcd272..10bd579363c 100644 --- a/spec/models/account_domain_block_spec.rb +++ b/spec/models/account_domain_block_spec.rb @@ -1,18 +1,20 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe AccountDomainBlock, type: :model do +RSpec.describe AccountDomainBlock do it 'removes blocking cache after creation' do account = Fabricate(:account) Rails.cache.write("exclude_domains_for:#{account.id}", 'a.domain.already.blocked') - AccountDomainBlock.create!(account: account, domain: 'a.domain.blocked.later') + described_class.create!(account: account, domain: 'a.domain.blocked.later') expect(Rails.cache.exist?("exclude_domains_for:#{account.id}")).to be false end it 'removes blocking cache after destruction' do account = Fabricate(:account) - block = AccountDomainBlock.create!(account: account, domain: 'domain') + block = described_class.create!(account: account, domain: 'domain') Rails.cache.write("exclude_domains_for:#{account.id}", 'domain') block.destroy! diff --git a/spec/models/account_filter_spec.rb b/spec/models/account_filter_spec.rb index c2bd8c22024..fa47b5954a8 100644 --- a/spec/models/account_filter_spec.rb +++ b/spec/models/account_filter_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe AccountFilter do @@ -16,4 +18,49 @@ describe AccountFilter do expect { filter.results }.to raise_error(/wrong/) end end + + describe 'with origin and by_domain interacting' do + let!(:local_account) { Fabricate(:account, domain: nil) } + let!(:remote_account_one) { Fabricate(:account, domain: 'example.org') } + let(:remote_account_two) { Fabricate(:account, domain: 'other.domain') } + + it 'works with domain first and origin remote' do + filter = described_class.new(by_domain: 'example.org', origin: 'remote') + expect(filter.results).to contain_exactly(remote_account_one) + end + + it 'works with domain last and origin remote' do + filter = described_class.new(origin: 'remote', by_domain: 'example.org') + expect(filter.results).to contain_exactly(remote_account_one) + end + + it 'works with domain first and origin local' do + filter = described_class.new(by_domain: 'example.org', origin: 'local') + expect(filter.results).to contain_exactly(local_account) + end + + it 'works with domain last and origin local' do + filter = described_class.new(origin: 'local', by_domain: 'example.org') + expect(filter.results).to contain_exactly(remote_account_one) + end + end + + describe 'with username' do + let!(:local_account) { Fabricate(:account, domain: nil, username: 'validUserName') } + + it 'works with @ at the beginning of the username' do + filter = described_class.new(username: '@validUserName') + expect(filter.results).to contain_exactly(local_account) + end + + it 'does not work with more than one @ at the beginning of the username' do + filter = described_class.new(username: '@@validUserName') + expect(filter.results).to_not contain_exactly(local_account) + end + + it 'does not work with @ outside the beginning of the username' do + filter = described_class.new(username: 'validUserName@') + expect(filter.results).to_not contain_exactly(local_account) + end + end end diff --git a/spec/models/account_migration_spec.rb b/spec/models/account_migration_spec.rb index 5f66fe8da36..1f32c6082ef 100644 --- a/spec/models/account_migration_spec.rb +++ b/spec/models/account_migration_spec.rb @@ -1,19 +1,21 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe AccountMigration, type: :model do +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) { AccountMigration.new(account: source_account, acct: target_acct) } - context 'with valid properties' do let(:target_account) { Fabricate(:account, username: 'target', domain: 'remote.org') } before do target_account.aliases.create!(acct: source_account.acct) - service_double = double + service_double = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(service_double) allow(service_double).to receive(:call).with(target_acct, anything).and_return(target_account) end @@ -23,11 +25,11 @@ RSpec.describe AccountMigration, type: :model do end end - context 'with unresolveable account' do + context 'with unresolvable account' do let(:target_acct) { 'target@remote' } before do - service_double = double + service_double = instance_double(ResolveAccountService) allow(ResolveAccountService).to receive(:new).and_return(service_double) allow(service_double).to receive(:call).with(target_acct, anything).and_return(nil) end diff --git a/spec/models/account_moderation_note_spec.rb b/spec/models/account_moderation_note_spec.rb deleted file mode 100644 index 69bd5500a5b..00000000000 --- a/spec/models/account_moderation_note_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe AccountModerationNote, type: :model do -end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index f3ad198777b..b5d942412e1 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Account, type: :model do - context do +RSpec.describe Account do + context 'with an account record' do subject { Fabricate(:account) } let(:bob) { Fabricate(:account, username: 'bob') } @@ -18,7 +20,9 @@ RSpec.describe Account, type: :model do end context 'when the account is of a local user' do - let!(:subject) { Fabricate(:user, email: 'foo+bar@domain.org').account } + subject { local_user_account } + + let!(:local_user_account) { Fabricate(:user, email: 'foo+bar@domain.org').account } it 'creates a canonical domain block' do subject.suspend! @@ -169,7 +173,7 @@ RSpec.describe Account, type: :model do describe '#possibly_stale?' do let(:account) { Fabricate(:account, last_webfingered_at: last_webfingered_at) } - context 'last_webfingered_at is nil' do + context 'when last_webfingered_at is nil' do let(:last_webfingered_at) { nil } it 'returns true' do @@ -177,7 +181,7 @@ RSpec.describe Account, type: :model do end end - context 'last_webfingered_at is more than 24 hours before' do + context 'when last_webfingered_at is more than 24 hours before' do let(:last_webfingered_at) { 25.hours.ago } it 'returns true' do @@ -185,7 +189,7 @@ RSpec.describe Account, type: :model do end end - context 'last_webfingered_at is less than 24 hours before' do + context 'when last_webfingered_at is less than 24 hours before' do let(:last_webfingered_at) { 23.hours.ago } it 'returns false' do @@ -198,7 +202,7 @@ RSpec.describe Account, type: :model do let(:account) { Fabricate(:account, domain: domain) } let(:acct) { account.acct } - context 'domain is nil' do + context 'when domain is nil' do let(:domain) { nil } it 'returns nil' do @@ -211,7 +215,7 @@ RSpec.describe Account, type: :model do end end - context 'domain is present' do + context 'when domain is present' do let(:domain) { 'example.com' } it 'calls ResolveAccountService#call' do @@ -337,7 +341,7 @@ RSpec.describe Account, type: :model do it 'returns the domains blocked by the account' do account = Fabricate(:account) account.block_domain!('domain') - expect(account.excluded_from_timeline_domains).to match_array ['domain'] + expect(account.excluded_from_timeline_domains).to contain_exactly('domain') end end @@ -352,7 +356,7 @@ RSpec.describe Account, type: :model do end it 'does not return suspended users' do - match = Fabricate( + Fabricate( :account, display_name: 'Display Name', username: 'username', @@ -360,7 +364,7 @@ RSpec.describe Account, type: :model do suspended: true ) - results = Account.search_for('username') + results = described_class.search_for('username') expect(results).to eq [] end @@ -373,7 +377,7 @@ RSpec.describe Account, type: :model do match.user.update(approved: false) - results = Account.search_for('username') + results = described_class.search_for('username') expect(results).to eq [] end @@ -386,7 +390,7 @@ RSpec.describe Account, type: :model do match.user.update(confirmed_at: nil) - results = Account.search_for('username') + results = described_class.search_for('username') expect(results).to eq [] end @@ -398,7 +402,7 @@ RSpec.describe Account, type: :model do domain: 'example.com' ) - results = Account.search_for('A?l\i:c e') + results = described_class.search_for('A?l\i:c e') expect(results).to eq [match] end @@ -410,7 +414,7 @@ RSpec.describe Account, type: :model do domain: 'example.com' ) - results = Account.search_for('display') + results = described_class.search_for('display') expect(results).to eq [match] end @@ -422,7 +426,7 @@ RSpec.describe Account, type: :model do domain: 'example.com' ) - results = Account.search_for('username') + results = described_class.search_for('username') expect(results).to eq [match] end @@ -434,19 +438,19 @@ RSpec.describe Account, type: :model do domain: 'example.com' ) - results = Account.search_for('example') + results = described_class.search_for('example') expect(results).to eq [match] end it 'limits by 10 by default' do 11.times.each { Fabricate(:account, display_name: 'Display Name') } - results = Account.search_for('display') + results = described_class.search_for('display') expect(results.size).to eq 10 end it 'accepts arbitrary limits' do 2.times.each { Fabricate(:account, display_name: 'Display Name') } - results = Account.search_for('display', limit: 1) + results = described_class.search_for('display', limit: 1) expect(results.size).to eq 1 end @@ -456,7 +460,7 @@ RSpec.describe Account, type: :model do { display_name: 'Display Name', username: 'username', domain: 'example.com' }, ].map(&method(:Fabricate).curry(2).call(:account)) - results = Account.search_for('username') + results = described_class.search_for('username') expect(results).to eq matches end end @@ -474,24 +478,24 @@ RSpec.describe Account, type: :model do ) account.follow!(match) - results = Account.advanced_search_for('A?l\i:c e', account, limit: 10, following: true) + results = described_class.advanced_search_for('A?l\i:c e', account, limit: 10, following: true) expect(results).to eq [match] end it 'does not return non-followed accounts' do - match = Fabricate( + Fabricate( :account, display_name: 'A & l & i & c & e', username: 'username', domain: 'example.com' ) - results = Account.advanced_search_for('A?l\i:c e', account, limit: 10, following: true) + results = described_class.advanced_search_for('A?l\i:c e', account, limit: 10, following: true) expect(results).to eq [] end it 'does not return suspended users' do - match = Fabricate( + Fabricate( :account, display_name: 'Display Name', username: 'username', @@ -499,7 +503,7 @@ RSpec.describe Account, type: :model do suspended: true ) - results = Account.advanced_search_for('username', account, limit: 10, following: true) + results = described_class.advanced_search_for('username', account, limit: 10, following: true) expect(results).to eq [] end @@ -512,7 +516,7 @@ RSpec.describe Account, type: :model do match.user.update(approved: false) - results = Account.advanced_search_for('username', account, limit: 10, following: true) + results = described_class.advanced_search_for('username', account, limit: 10, following: true) expect(results).to eq [] end @@ -525,13 +529,13 @@ RSpec.describe Account, type: :model do match.user.update(confirmed_at: nil) - results = Account.advanced_search_for('username', account, limit: 10, following: true) + results = described_class.advanced_search_for('username', account, limit: 10, following: true) expect(results).to eq [] end end it 'does not return suspended users' do - match = Fabricate( + Fabricate( :account, display_name: 'Display Name', username: 'username', @@ -539,7 +543,7 @@ RSpec.describe Account, type: :model do suspended: true ) - results = Account.advanced_search_for('username', account) + results = described_class.advanced_search_for('username', account) expect(results).to eq [] end @@ -552,7 +556,7 @@ RSpec.describe Account, type: :model do match.user.update(approved: false) - results = Account.advanced_search_for('username', account) + results = described_class.advanced_search_for('username', account) expect(results).to eq [] end @@ -565,7 +569,7 @@ RSpec.describe Account, type: :model do match.user.update(confirmed_at: nil) - results = Account.advanced_search_for('username', account) + results = described_class.advanced_search_for('username', account) expect(results).to eq [] end @@ -577,19 +581,19 @@ RSpec.describe Account, type: :model do domain: 'example.com' ) - results = Account.advanced_search_for('A?l\i:c e', account) + results = described_class.advanced_search_for('A?l\i:c e', account) expect(results).to eq [match] end it 'limits by 10 by default' do 11.times { Fabricate(:account, display_name: 'Display Name') } - results = Account.advanced_search_for('display', account) + results = described_class.advanced_search_for('display', account) expect(results.size).to eq 10 end it 'accepts arbitrary limits' do 2.times { Fabricate(:account, display_name: 'Display Name') } - results = Account.advanced_search_for('display', account, limit: 1) + results = described_class.advanced_search_for('display', account, limit: 1) expect(results.size).to eq 1 end @@ -598,7 +602,7 @@ RSpec.describe Account, type: :model do followed_match = Fabricate(:account, username: 'Matcher') Fabricate(:follow, account: account, target_account: followed_match) - results = Account.advanced_search_for('match', account) + results = described_class.advanced_search_for('match', account) expect(results).to eq [followed_match, match] expect(results.first.rank).to be > results.last.rank end @@ -637,31 +641,31 @@ RSpec.describe Account, type: :model do describe '.following_map' do it 'returns an hash' do - expect(Account.following_map([], 1)).to be_a Hash + expect(described_class.following_map([], 1)).to be_a Hash end end describe '.followed_by_map' do it 'returns an hash' do - expect(Account.followed_by_map([], 1)).to be_a Hash + expect(described_class.followed_by_map([], 1)).to be_a Hash end end describe '.blocking_map' do it 'returns an hash' do - expect(Account.blocking_map([], 1)).to be_a Hash + expect(described_class.blocking_map([], 1)).to be_a Hash end end describe '.requested_map' do it 'returns an hash' do - expect(Account.requested_map([], 1)).to be_a Hash + expect(described_class.requested_map([], 1)).to be_a Hash end end describe '.requested_by_map' do it 'returns an hash' do - expect(Account.requested_by_map([], 1)).to be_a Hash + expect(described_class.requested_by_map([], 1)).to be_a Hash end end @@ -696,18 +700,12 @@ RSpec.describe Account, type: :model do expect(subject.match('Check this out https://medium.com/@alice/some-article#.abcdef123')).to be_nil end - xit 'does not match URL querystring' do + it 'does not match URL query string' do expect(subject.match('https://example.com/?x=@alice')).to be_nil end end describe 'validations' do - it 'has a valid fabricator' do - account = Fabricate.build(:account) - account.valid? - expect(account).to be_valid - end - it 'is invalid without a username' do account = Fabricate.build(:account, username: nil) account.valid? @@ -721,10 +719,10 @@ RSpec.describe Account, type: :model 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 @@ -745,9 +743,9 @@ RSpec.describe Account, type: :model 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 @@ -838,7 +836,7 @@ RSpec.describe Account, type: :model do { username: 'b', domain: 'b' }, ].map(&method(:Fabricate).curry(2).call(:account)) - expect(Account.where('id > 0').alphabetic).to eq matches + expect(described_class.where('id > 0').alphabetic).to eq matches end end @@ -847,7 +845,7 @@ RSpec.describe Account, type: :model do match = Fabricate(:account, display_name: 'pattern and suffix') Fabricate(:account, display_name: 'prefix and pattern') - expect(Account.matches_display_name('pattern')).to eq [match] + expect(described_class.matches_display_name('pattern')).to eq [match] end end @@ -856,40 +854,40 @@ RSpec.describe Account, type: :model do match = Fabricate(:account, username: 'pattern_and_suffix') Fabricate(:account, username: 'prefix_and_pattern') - expect(Account.matches_username('pattern')).to eq [match] + expect(described_class.matches_username('pattern')).to eq [match] end end describe 'by_domain_and_subdomains' do it 'returns exact domain matches' do account = Fabricate(:account, domain: 'example.com') - expect(Account.by_domain_and_subdomains('example.com')).to eq [account] + expect(described_class.by_domain_and_subdomains('example.com')).to eq [account] end it 'returns subdomains' do account = Fabricate(:account, domain: 'foo.example.com') - expect(Account.by_domain_and_subdomains('example.com')).to eq [account] + expect(described_class.by_domain_and_subdomains('example.com')).to eq [account] end it 'does not return partially matching domains' do account = Fabricate(:account, domain: 'grexample.com') - expect(Account.by_domain_and_subdomains('example.com')).to_not eq [account] + expect(described_class.by_domain_and_subdomains('example.com')).to_not eq [account] end end 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(Account.remote).to match_array([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(Account.where('id > 0').local).to match_array([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 @@ -900,30 +898,30 @@ RSpec.describe Account, type: :model do matches[index] = Fabricate(:account, domain: matches[index]) end - expect(Account.where('id > 0').partitioned).to match_array(matches) + expect(described_class.where('id > 0').partitioned).to match_array(matches) end end describe 'recent' do it 'returns a relation of accounts sorted by recent creation' do - matches = 2.times.map { Fabricate(:account) } - expect(Account.where('id > 0').recent).to match_array(matches) + matches = Array.new(2) { Fabricate(:account) } + expect(described_class.where('id > 0').recent).to match_array(matches) end end 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(Account.silenced).to match_array([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(Account.suspended).to match_array([account_1]) + suspended_account = Fabricate(:account, suspended: true) + _account = Fabricate(:account, suspended: false) + expect(described_class.suspended).to contain_exactly(suspended_account) end end @@ -945,32 +943,32 @@ RSpec.describe Account, type: :model do end it 'returns every usable non-suspended account' do - expect(Account.searchable).to match_array([silenced_local, silenced_remote, local_account, remote_account]) + expect(described_class.searchable).to contain_exactly(silenced_local, silenced_remote, local_account, remote_account) end it 'does not mess with previously-applied scopes' do - expect(Account.where.not(id: remote_account.id).searchable).to match_array([silenced_local, silenced_remote, local_account]) + expect(described_class.where.not(id: remote_account.id).searchable).to contain_exactly(silenced_local, silenced_remote, local_account) end end end context 'when is local' do - # Test disabled because test environment omits autogenerating keys for performance - xit 'generates keys' do - account = Account.create!(domain: nil, username: Faker::Internet.user_name(separators: ['_'])) - expect(account.keypair.private?).to be true + it 'generates keys' do + account = described_class.create!(domain: nil, username: Faker::Internet.user_name(separators: ['_'])) + expect(account.keypair).to be_private + expect(account.keypair).to be_public end end context 'when is remote' do it 'does not generate keys' do key = OpenSSL::PKey::RSA.new(1024).public_key - account = Account.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 = Account.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 @@ -990,7 +988,7 @@ RSpec.describe Account, type: :model do threads = Array.new(increment_by) do Thread.new do true while wait_for_start - Account.find(subject.id).increment_count!(:followers_count) + described_class.find(subject.id).increment_count!(:followers_count) end end diff --git a/spec/models/account_statuses_cleanup_policy_spec.rb b/spec/models/account_statuses_cleanup_policy_spec.rb index d170050fc5d..7405bdfa2d7 100644 --- a/spec/models/account_statuses_cleanup_policy_spec.rb +++ b/spec/models/account_statuses_cleanup_policy_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe AccountStatusesCleanupPolicy, type: :model do +RSpec.describe AccountStatusesCleanupPolicy do let(:account) { Fabricate(:account, username: 'alice', domain: nil) } describe 'validation' do @@ -264,10 +266,10 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do let!(:self_bookmarked) { Fabricate(:status, created_at: 1.year.ago, account: account) } let!(:status_with_poll) { Fabricate(:status, created_at: 1.year.ago, account: account, poll_attributes: { account: account, voters_count: 0, options: %w(a b), expires_in: 2.days }) } let!(:status_with_media) { Fabricate(:status, created_at: 1.year.ago, account: account) } - let!(:faved4) { Fabricate(:status, created_at: 1.year.ago, account: account) } - let!(:faved5) { Fabricate(:status, created_at: 1.year.ago, account: account) } - let!(:reblogged4) { Fabricate(:status, created_at: 1.year.ago, account: account) } - let!(:reblogged5) { Fabricate(:status, created_at: 1.year.ago, account: account) } + let!(:faved_primary) { Fabricate(:status, created_at: 1.year.ago, account: account) } + 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) } @@ -278,10 +280,10 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do let(:account_statuses_cleanup_policy) { Fabricate(:account_statuses_cleanup_policy, account: account) } before do - 4.times { faved4.increment_count!(:favourites_count) } - 5.times { faved5.increment_count!(:favourites_count) } - 4.times { reblogged4.increment_count!(:reblogs_count) } - 5.times { reblogged5.increment_count!(:reblogs_count) } + 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) } end context 'when passed a max_id' do @@ -357,7 +359,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do 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, faved4.id, faved5.id, reblogged4.id, reblogged5.id) + 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) end end @@ -376,7 +378,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do 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, faved4.id, faved5.id, reblogged4.id, reblogged5.id) + 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) end end @@ -395,7 +397,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do 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, faved4.id, faved5.id, reblogged4.id, reblogged5.id) + 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) end end @@ -414,7 +416,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do 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, faved4.id, faved5.id, reblogged4.id, reblogged5.id) + 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) end end @@ -433,7 +435,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do 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, faved4.id, faved5.id, reblogged4.id, reblogged5.id) + 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) end end @@ -452,7 +454,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do 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, faved4.id, faved5.id, reblogged4.id, reblogged5.id) + 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) end end @@ -475,7 +477,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do 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, faved4.id, faved5.id, reblogged4.id, reblogged5.id) + 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) end end @@ -494,7 +496,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do end it 'returns only normal statuses for deletion' do - expect(subject.pluck(:id)).to match_array([very_old_status.id, faved4.id, faved5.id, reblogged4.id, reblogged5.id]) + expect(subject.pluck(:id)).to contain_exactly(very_old_status.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) end end @@ -508,7 +510,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do end it 'does not return the toot reblogged 5 times' do - expect(subject.pluck(:id)).to_not include(reblogged5.id) + expect(subject.pluck(:id)).to_not include(reblogged_secondary.id) end it 'does not return the unrelated toot' do @@ -516,7 +518,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do end it 'returns old statuses not reblogged as much' do - expect(subject.pluck(:id)).to include(very_old_status.id, faved4.id, faved5.id, reblogged4.id) + expect(subject.pluck(:id)).to include(very_old_status.id, faved_primary.id, faved_secondary.id, reblogged_primary.id) end end @@ -530,7 +532,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do end it 'does not return the toot faved 5 times' do - expect(subject.pluck(:id)).to_not include(faved5.id) + expect(subject.pluck(:id)).to_not include(faved_secondary.id) end it 'does not return the unrelated toot' do @@ -538,7 +540,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do end it 'returns old statuses not faved as much' do - expect(subject.pluck(:id)).to include(very_old_status.id, faved4.id, reblogged4.id, reblogged5.id) + expect(subject.pluck(:id)).to include(very_old_status.id, faved_primary.id, reblogged_primary.id, reblogged_secondary.id) end end end diff --git a/spec/models/account_warning_preset_spec.rb b/spec/models/account_warning_preset_spec.rb new file mode 100644 index 00000000000..f171df7c974 --- /dev/null +++ b/spec/models/account_warning_preset_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe AccountWarningPreset do + describe 'alphabetical' do + let(:first) { Fabricate(:account_warning_preset, title: 'aaa', text: 'aaa') } + let(:second) { Fabricate(:account_warning_preset, title: 'bbb', text: 'aaa') } + let(:third) { Fabricate(:account_warning_preset, title: 'bbb', text: 'bbb') } + + it 'returns records in order of title and text' do + results = described_class.alphabetic + + expect(results).to eq([first, second, third]) + end + end +end diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb index 7248356e532..b47561dd485 100644 --- a/spec/models/admin/account_action_spec.rb +++ b/spec/models/admin/account_action_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Admin::AccountAction, type: :model do +RSpec.describe Admin::AccountAction do let(:account_action) { described_class.new } describe '#save!' do @@ -18,7 +20,7 @@ RSpec.describe Admin::AccountAction, type: :model do ) end - context 'type is "disable"' do + context 'when type is "disable"' do let(:type) { 'disable' } it 'disable user' do @@ -27,7 +29,7 @@ RSpec.describe Admin::AccountAction, type: :model do end end - context 'type is "silence"' do + context 'when type is "silence"' do let(:type) { 'silence' } it 'silences account' do @@ -36,7 +38,7 @@ RSpec.describe Admin::AccountAction, type: :model do end end - context 'type is "suspend"' do + context 'when type is "suspend"' do let(:type) { 'suspend' } it 'suspends account' do @@ -53,10 +55,26 @@ RSpec.describe Admin::AccountAction, type: :model do end end + context 'when type is invalid' do + let(:type) { 'whatever' } + + it 'raises an invalid record error' do + expect { subject }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + context 'when type is not given' do + let(:type) { '' } + + it 'raises an invalid record error' do + expect { subject }.to raise_error(ActiveRecord::RecordInvalid) + end + end + it 'creates Admin::ActionLog' do expect do subject - end.to change { Admin::ActionLog.count }.by 1 + end.to change(Admin::ActionLog, :count).by 1 end it 'calls process_email!' do @@ -73,7 +91,7 @@ RSpec.describe Admin::AccountAction, type: :model do describe '#report' do subject { account_action.report } - context 'report_id.present?' do + context 'with report_id.present?' do before do account_action.report_id = Fabricate(:report).id end @@ -83,7 +101,7 @@ RSpec.describe Admin::AccountAction, type: :model do end end - context '!report_id.present?' do + context 'with !report_id.present?' do it 'returns nil' do expect(subject).to be_nil end @@ -93,7 +111,7 @@ RSpec.describe Admin::AccountAction, type: :model do describe '#with_report?' do subject { account_action.with_report? } - context '!report.nil?' do + context 'with !report.nil?' do before do account_action.report_id = Fabricate(:report).id end @@ -103,7 +121,7 @@ RSpec.describe Admin::AccountAction, type: :model do end end - context '!(!report.nil?)' do + context 'with !(!report.nil?)' do it 'returns false' do expect(subject).to be false end @@ -113,7 +131,7 @@ RSpec.describe Admin::AccountAction, type: :model do describe '.types_for_account' do subject { described_class.types_for_account(account) } - context 'account.local?' do + context 'when Account.local?' do let(:account) { Fabricate(:account, domain: nil) } it 'returns ["none", "disable", "sensitive", "silence", "suspend"]' do @@ -121,7 +139,7 @@ RSpec.describe Admin::AccountAction, type: :model do end end - context '!account.local?' do + context 'with !account.local?' do let(:account) { Fabricate(:account, domain: 'hoge.com') } it 'returns ["sensitive", "silence", "suspend"]' do diff --git a/spec/models/admin/action_log_spec.rb b/spec/models/admin/action_log_spec.rb index 3495cc51414..1e3649b833b 100644 --- a/spec/models/admin/action_log_spec.rb +++ b/spec/models/admin/action_log_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe Admin::ActionLog, type: :model do +RSpec.describe Admin::ActionLog do describe '#action' do it 'returns action' do action_log = described_class.new(action: 'hoge') diff --git a/spec/models/admin/appeal_filter_spec.rb b/spec/models/admin/appeal_filter_spec.rb new file mode 100644 index 00000000000..e840bc3bc12 --- /dev/null +++ b/spec/models/admin/appeal_filter_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::AppealFilter do + describe '#results' do + let(:approved_appeal) { Fabricate(:appeal, approved_at: 10.days.ago) } + let(:not_approved_appeal) { Fabricate(:appeal, approved_at: nil) } + + it 'returns filtered appeals' do + filter = described_class.new(status: 'approved') + + expect(filter.results).to eq([approved_appeal]) + end + end +end diff --git a/spec/models/announcement_mute_spec.rb b/spec/models/announcement_mute_spec.rb deleted file mode 100644 index 9d0e4c9037f..00000000000 --- a/spec/models/announcement_mute_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe AnnouncementMute, type: :model do -end diff --git a/spec/models/announcement_reaction_spec.rb b/spec/models/announcement_reaction_spec.rb deleted file mode 100644 index f6e15158406..00000000000 --- a/spec/models/announcement_reaction_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe AnnouncementReaction, type: :model do -end diff --git a/spec/models/announcement_spec.rb b/spec/models/announcement_spec.rb deleted file mode 100644 index 7f7b647a9e5..00000000000 --- a/spec/models/announcement_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe Announcement, type: :model do -end diff --git a/spec/models/appeal_spec.rb b/spec/models/appeal_spec.rb index 14062dc4f4f..12373a9494e 100644 --- a/spec/models/appeal_spec.rb +++ b/spec/models/appeal_spec.rb @@ -1,5 +1,38 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Appeal, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +describe Appeal do + describe 'scopes' do + describe 'approved' do + let(:approved_appeal) { Fabricate(:appeal, approved_at: 10.days.ago) } + let(:not_approved_appeal) { Fabricate(:appeal, approved_at: nil) } + + it 'finds the correct records' do + results = described_class.approved + expect(results).to eq([approved_appeal]) + end + end + + describe 'rejected' do + let(:rejected_appeal) { Fabricate(:appeal, rejected_at: 10.days.ago) } + let(:not_rejected_appeal) { Fabricate(:appeal, rejected_at: nil) } + + it 'finds the correct records' do + results = described_class.rejected + expect(results).to eq([rejected_appeal]) + end + end + + describe 'pending' do + let(:approved_appeal) { Fabricate(:appeal, approved_at: 10.days.ago) } + let(:rejected_appeal) { Fabricate(:appeal, rejected_at: 10.days.ago) } + let(:pending_appeal) { Fabricate(:appeal, rejected_at: nil, approved_at: nil) } + + it 'finds the correct records' do + results = described_class.pending + expect(results).to eq([pending_appeal]) + end + end + end end diff --git a/spec/models/backup_spec.rb b/spec/models/backup_spec.rb deleted file mode 100644 index 45230986d7a..00000000000 --- a/spec/models/backup_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe Backup, type: :model do -end diff --git a/spec/models/block_spec.rb b/spec/models/block_spec.rb index 1fd60c29d21..8249503c592 100644 --- a/spec/models/block_spec.rb +++ b/spec/models/block_spec.rb @@ -1,12 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Block, type: :model do +RSpec.describe Block do describe 'validations' do - it 'has a valid fabricator' do - block = Fabricate.build(:block) - expect(block).to be_valid - end - it 'is invalid without an account' do block = Fabricate.build(:block, account: nil) block.valid? @@ -26,7 +23,7 @@ RSpec.describe Block, type: :model do Rails.cache.write("exclude_account_ids_for:#{account.id}", []) Rails.cache.write("exclude_account_ids_for:#{target_account.id}", []) - Block.create!(account: account, target_account: target_account) + described_class.create!(account: account, target_account: target_account) expect(Rails.cache.exist?("exclude_account_ids_for:#{account.id}")).to be false expect(Rails.cache.exist?("exclude_account_ids_for:#{target_account.id}")).to be false @@ -35,7 +32,7 @@ RSpec.describe Block, type: :model do it 'removes blocking cache after destruction' do account = Fabricate(:account) target_account = Fabricate(:account) - block = Block.create!(account: account, target_account: target_account) + block = described_class.create!(account: account, target_account: target_account) Rails.cache.write("exclude_account_ids_for:#{account.id}", [target_account.id]) Rails.cache.write("exclude_account_ids_for:#{target_account.id}", [account.id]) diff --git a/spec/models/canonical_email_block_spec.rb b/spec/models/canonical_email_block_spec.rb index 8e0050d65ac..0acff823779 100644 --- a/spec/models/canonical_email_block_spec.rb +++ b/spec/models/canonical_email_block_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe CanonicalEmailBlock, type: :model do +RSpec.describe CanonicalEmailBlock do describe '#email=' do let(:target_hash) { '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b' } diff --git a/spec/models/concerns/account_counters_spec.rb b/spec/models/concerns/account_counters_spec.rb index 4350496e79e..fb02d79f118 100644 --- a/spec/models/concerns/account_counters_spec.rb +++ b/spec/models/concerns/account_counters_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe AccountCounters do diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb index 50ff0b149a2..84e2c91a85d 100644 --- a/spec/models/concerns/account_interactions_spec.rb +++ b/spec/models/concerns/account_interactions_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe AccountInteractions do @@ -11,14 +13,14 @@ describe AccountInteractions do describe '.following_map' do subject { Account.following_map(target_account_ids, account_id) } - context 'account with Follow' do + 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 'account without Follow' do + context 'when Account without Follow' do it 'returns {}' do expect(subject).to eq({}) end @@ -28,14 +30,14 @@ describe AccountInteractions do describe '.followed_by_map' do subject { Account.followed_by_map(target_account_ids, account_id) } - context 'account with Follow' do + 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 'account without Follow' do + context 'when Account without Follow' do it 'returns {}' do expect(subject).to eq({}) end @@ -45,14 +47,14 @@ describe AccountInteractions do describe '.blocking_map' do subject { Account.blocking_map(target_account_ids, account_id) } - context 'account with Block' do + 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 'account without Block' do + context 'when Account without Block' do it 'returns {}' do expect(subject).to eq({}) end @@ -62,12 +64,12 @@ describe AccountInteractions do describe '.muting_map' do subject { Account.muting_map(target_account_ids, account_id) } - context 'account with Mute' do + context 'when Account with Mute' do before do Fabricate(:mute, target_account: target_account, account: account, hide_notifications: hide) end - context 'if Mute#hide_notifications?' do + context 'when Mute#hide_notifications?' do let(:hide) { true } it 'returns { target_account_id => { notifications: true } }' do @@ -75,7 +77,7 @@ describe AccountInteractions do end end - context 'unless Mute#hide_notifications?' do + context 'when not Mute#hide_notifications?' do let(:hide) { false } it 'returns { target_account_id => { notifications: false } }' do @@ -84,7 +86,7 @@ describe AccountInteractions do end end - context 'account without Mute' do + context 'when Account without Mute' do it 'returns {}' do expect(subject).to eq({}) end @@ -110,8 +112,8 @@ describe AccountInteractions do describe '#mute!' do subject { account.mute!(target_account, notifications: arg_notifications) } - context 'Mute does not exist yet' do - context 'arg :notifications is nil' do + 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 @@ -121,7 +123,7 @@ describe AccountInteractions do end end - context 'arg :notifications is false' do + context 'when arg :notifications is false' do let(:arg_notifications) { false } it 'creates Mute, and returns Mute' do @@ -131,7 +133,7 @@ describe AccountInteractions do end end - context 'arg :notifications is true' do + context 'when arg :notifications is true' do let(:arg_notifications) { true } it 'creates Mute, and returns Mute' do @@ -142,7 +144,7 @@ describe AccountInteractions do end end - context 'Mute already exists' do + context 'when Mute already exists' do before do account.mute_relationships << mute end @@ -154,10 +156,10 @@ describe AccountInteractions do hide_notifications: hide_notifications) end - context 'mute.hide_notifications is true' do + context 'when mute.hide_notifications is true' do let(:hide_notifications) { true } - context 'arg :notifications is nil' do + context 'when arg :notifications is nil' do let(:arg_notifications) { nil } it 'returns Mute without updating mute.hide_notifications' do @@ -167,7 +169,7 @@ describe AccountInteractions do end end - context 'arg :notifications is false' do + context 'when arg :notifications is false' do let(:arg_notifications) { false } it 'returns Mute, and updates mute.hide_notifications false' do @@ -177,7 +179,7 @@ describe AccountInteractions do end end - context 'arg :notifications is true' do + context 'when arg :notifications is true' do let(:arg_notifications) { true } it 'returns Mute without updating mute.hide_notifications' do @@ -188,10 +190,10 @@ describe AccountInteractions do end end - context 'mute.hide_notifications is false' do + context 'when mute.hide_notifications is false' do let(:hide_notifications) { false } - context 'arg :notifications is nil' do + context 'when arg :notifications is nil' do let(:arg_notifications) { nil } it 'returns Mute, and updates mute.hide_notifications true' do @@ -201,7 +203,7 @@ describe AccountInteractions do end end - context 'arg :notifications is false' do + context 'when arg :notifications is false' do let(:arg_notifications) { false } it 'returns Mute without updating mute.hide_notifications' do @@ -211,7 +213,7 @@ describe AccountInteractions do end end - context 'arg :notifications is true' do + context 'when arg :notifications is true' do let(:arg_notifications) { true } it 'returns Mute, and updates mute.hide_notifications true' do @@ -251,7 +253,7 @@ describe AccountInteractions do describe '#unfollow!' do subject { account.unfollow!(target_account) } - context 'following target_account' do + 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 @@ -259,7 +261,7 @@ describe AccountInteractions do end end - context 'not following target_account' do + context 'when not following target_account' do it 'returns nil' do expect(subject).to be_nil end @@ -269,7 +271,7 @@ describe AccountInteractions do describe '#unblock!' do subject { account.unblock!(target_account) } - context 'blocking target_account' do + 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 @@ -277,7 +279,7 @@ describe AccountInteractions do end end - context 'not blocking target_account' do + context 'when not blocking target_account' do it 'returns nil' do expect(subject).to be_nil end @@ -287,7 +289,7 @@ describe AccountInteractions do describe '#unmute!' do subject { account.unmute!(target_account) } - context 'muting target_account' do + 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 @@ -295,7 +297,7 @@ describe AccountInteractions do end end - context 'not muting target_account' do + context 'when not muting target_account' do it 'returns nil' do expect(subject).to be_nil end @@ -307,7 +309,7 @@ describe AccountInteractions do let(:conversation) { Fabricate(:conversation) } - context 'muting the conversation' do + context 'when muting the conversation' do it 'returns destroyed ConversationMute' do account.conversation_mutes.create(conversation: conversation) expect(subject).to be_a ConversationMute @@ -315,7 +317,7 @@ describe AccountInteractions do end end - context 'not muting the conversation' do + context 'when not muting the conversation' do it 'returns nil' do expect(subject).to be_nil end @@ -327,7 +329,7 @@ describe AccountInteractions do let(:domain) { 'example.com' } - context 'blocking the domain' do + 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 @@ -336,7 +338,7 @@ describe AccountInteractions do end end - context 'unblocking the domain' do + context 'when unblocking the domain' do it 'returns nil' do expect(subject).to be_nil end @@ -346,14 +348,14 @@ describe AccountInteractions do describe '#following?' do subject { account.following?(target_account) } - context 'following target_account' do + 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 'not following target_account' do + context 'when not following target_account' do it 'returns false' do expect(subject).to be false end @@ -363,14 +365,14 @@ describe AccountInteractions do describe '#followed_by?' do subject { account.followed_by?(target_account) } - context 'followed by target_account' do + 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 'not followed by target_account' do + context 'when not followed by target_account' do it 'returns false' do expect(subject).to be false end @@ -380,14 +382,14 @@ describe AccountInteractions do describe '#blocking?' do subject { account.blocking?(target_account) } - context 'blocking target_account' do + 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 'not blocking target_account' do + context 'when not blocking target_account' do it 'returns false' do expect(subject).to be false end @@ -399,7 +401,7 @@ describe AccountInteractions do let(:domain) { 'example.com' } - context 'blocking the domain' do + 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 @@ -407,7 +409,7 @@ describe AccountInteractions do end end - context 'not blocking the domain' do + context 'when not blocking the domain' do it 'returns false' do expect(subject).to be false end @@ -417,7 +419,7 @@ describe AccountInteractions do describe '#muting?' do subject { account.muting?(target_account) } - context 'muting target_account' do + context 'when muting target_account' do it 'returns true' do mute = Fabricate(:mute, account: account, target_account: target_account) account.mute_relationships << mute @@ -425,7 +427,7 @@ describe AccountInteractions do end end - context 'not muting target_account' do + context 'when not muting target_account' do it 'returns false' do expect(subject).to be false end @@ -437,14 +439,14 @@ describe AccountInteractions do let(:conversation) { Fabricate(:conversation) } - context 'muting the conversation' do + context 'when muting the conversation' do it 'returns true' do account.conversation_mutes.create(conversation: conversation) expect(subject).to be true end end - context 'not muting the conversation' do + context 'when not muting the conversation' do it 'returns false' do expect(subject).to be false end @@ -459,7 +461,7 @@ describe AccountInteractions do account.mute_relationships << mute end - context 'muting notifications of target_account' do + context 'when muting notifications of target_account' do let(:hide) { true } it 'returns true' do @@ -467,7 +469,7 @@ describe AccountInteractions do end end - context 'not muting notifications of target_account' do + context 'when not muting notifications of target_account' do let(:hide) { false } it 'returns false' do @@ -479,14 +481,14 @@ describe AccountInteractions do describe '#requested?' do subject { account.requested?(target_account) } - context 'requested by target_account' do + 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 'not requested by target_account' do + context 'when not requested by target_account' do it 'returns false' do expect(subject).to be false end @@ -498,7 +500,7 @@ describe AccountInteractions do let(:status) { Fabricate(:status, account: account, favourites: favourites) } - context 'favorited' do + context 'when favorited' do let(:favourites) { [Fabricate(:favourite, account: account)] } it 'returns true' do @@ -506,7 +508,7 @@ describe AccountInteractions do end end - context 'not favorited' do + context 'when not favorited' do let(:favourites) { [] } it 'returns false' do @@ -520,7 +522,7 @@ describe AccountInteractions do let(:status) { Fabricate(:status, account: account, reblogs: reblogs) } - context 'reblogged' do + context 'with reblogged' do let(:reblogs) { [Fabricate(:status, account: account)] } it 'returns true' do @@ -528,7 +530,7 @@ describe AccountInteractions do end end - context 'not reblogged' do + context 'when not reblogged' do let(:reblogs) { [] } it 'returns false' do @@ -542,14 +544,14 @@ describe AccountInteractions do let(:status) { Fabricate(:status, account: account) } - context 'pinned' do + context 'when pinned' do it 'returns true' do Fabricate(:status_pin, account: account, status: status) expect(subject).to be true end end - context 'not pinned' do + context 'when not pinned' do it 'returns false' do expect(subject).to be false end @@ -558,17 +560,17 @@ describe AccountInteractions do describe '#remote_followers_hash' do let(:me) { Fabricate(:account, username: 'Me') } - let(:remote_1) { Fabricate(:account, username: 'alice', domain: 'example.org', uri: 'https://example.org/users/alice') } - let(:remote_2) { Fabricate(:account, username: 'bob', domain: 'example.org', uri: 'https://example.org/users/bob') } - let(:remote_3) { Fabricate(:account, username: 'instance-actor', domain: 'example.org', uri: 'https://example.org') } - let(:remote_4) { Fabricate(:account, username: 'eve', domain: 'foo.org', uri: 'https://foo.org/users/eve') } + 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_1.follow!(me) - remote_2.follow!(me) - remote_3.follow!(me) - remote_4.follow!(me) - me.follow!(remote_1) + 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 @@ -580,33 +582,33 @@ describe AccountInteractions do it 'invalidates cache as needed when removing or adding followers' do expect(me.remote_followers_hash('https://example.org/')).to eq '20aecbe774b3d61c25094370baf370012b9271c5b172ecedb05caff8d79ef0c7' - remote_3.unfollow!(me) + remote_instance_actor.unfollow!(me) expect(me.remote_followers_hash('https://example.org/')).to eq '707962e297b7bd94468a21bc8e506a1bcea607a9142cd64e27c9b106b2a5f6ec' - remote_1.unfollow!(me) + remote_alice.unfollow!(me) expect(me.remote_followers_hash('https://example.org/')).to eq '241b00794ce9b46aa864f3220afadef128318da2659782985bac5ed5bd436bff' - remote_1.follow!(me) + 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_1) { Fabricate(:account, username: 'alice', domain: 'example.org', uri: 'https://example.org/users/alice') } + let(:remote_alice) { Fabricate(:account, username: 'alice', domain: 'example.org', uri: 'https://example.org/users/alice') } before do - me.follow!(remote_1) + me.follow!(remote_alice) end it 'returns correct hash for local users' do - expect(remote_1.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me)) + 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_1.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me)) - me.unfollow!(remote_1) - expect(remote_1.local_followers_hash).to eq '0000000000000000000000000000000000000000000000000000000000000000' - me.follow!(remote_1) - expect(remote_1.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me)) + 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 @@ -681,4 +683,32 @@ describe AccountInteractions do 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 00000000000..46362936f4a --- /dev/null +++ b/spec/models/concerns/account_statuses_search_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe AccountStatusesSearch do + let(:account) { Fabricate(:account, indexable: indexable) } + + before do + allow(Chewy).to receive(:enabled?).and_return(true) + end + + describe '#enqueue_update_public_statuses_index' do + before do + allow(account).to receive(:enqueue_add_to_public_statuses_index) + allow(account).to receive(:enqueue_remove_from_public_statuses_index) + end + + context 'when account is indexable' do + let(:indexable) { true } + + it 'enqueues add_to_public_statuses_index and not to remove_from_public_statuses_index' do + account.enqueue_update_public_statuses_index + expect(account).to have_received(:enqueue_add_to_public_statuses_index) + expect(account).to_not have_received(:enqueue_remove_from_public_statuses_index) + end + end + + context 'when account is not indexable' do + let(:indexable) { false } + + it 'enqueues remove_from_public_statuses_index and not to add_to_public_statuses_index' do + account.enqueue_update_public_statuses_index + expect(account).to have_received(:enqueue_remove_from_public_statuses_index) + expect(account).to_not have_received(:enqueue_add_to_public_statuses_index) + end + end + end + + describe '#enqueue_add_to_public_statuses_index' do + let(:indexable) { true } + let(:worker) { AddToPublicStatusesIndexWorker } + + before do + allow(worker).to receive(:perform_async) + end + + it 'enqueues AddToPublicStatusesIndexWorker' do + account.enqueue_add_to_public_statuses_index + expect(worker).to have_received(:perform_async).with(account.id) + end + end + + describe '#enqueue_remove_from_public_statuses_index' do + let(:indexable) { false } + let(:worker) { RemoveFromPublicStatusesIndexWorker } + + before do + allow(worker).to receive(:perform_async) + end + + it 'enqueues RemoveFromPublicStatusesIndexWorker' do + account.enqueue_remove_from_public_statuses_index + expect(worker).to have_received(:perform_async).with(account.id) + end + end +end diff --git a/spec/models/concerns/remotable_spec.rb b/spec/models/concerns/remotable_spec.rb index 96452042760..b2aa56a7047 100644 --- a/spec/models/concerns/remotable_spec.rb +++ b/spec/models/concerns/remotable_spec.rb @@ -3,48 +3,47 @@ require 'rails_helper' RSpec.describe Remotable do - class Foo - def initialize - @attrs = {} - end + let(:foo_class) do + Class.new do + def initialize + @attrs = {} + end - def [](arg) - @attrs[arg] - end + def [](arg) + @attrs[arg] + end - def []=(arg1, arg2) - @attrs[arg1] = arg2 - end + def []=(arg1, arg2) + @attrs[arg1] = arg2 + end - def hoge=(arg); end + def hoge=(arg); end - def hoge_file_name; end + def hoge_file_name; end - def hoge_file_name=(arg); end + def hoge_file_name=(arg); end - def has_attribute?(arg); end + def has_attribute?(arg); end - def self.attachment_definitions - { hoge: nil } - end - end - - before do - class Foo - include Remotable - - remotable_attachment :hoge, 1.kilobyte + def self.attachment_definitions + { hoge: nil } + end end end let(:attribute_name) { "#{hoge}_remote_url".to_sym } let(:code) { 200 } let(:file) { 'filename="foo.txt"' } - let(:foo) { Foo.new } + let(:foo) { foo_class.new } let(:headers) { { 'content-disposition' => file } } let(:hoge) { :hoge } let(:url) { 'https://google.com' } + before do + foo_class.include described_class + foo_class.remotable_attachment :hoge, 1.kilobyte + end + it 'defines a method #hoge_remote_url=' do expect(foo).to respond_to(:hoge_remote_url=) end @@ -157,7 +156,7 @@ RSpec.describe Remotable do context 'when the response is successful' do let(:code) { 200 } - context 'and contains Content-Disposition header' do + context 'when contains Content-Disposition header' do let(:file) { 'filename="foo.txt"' } let(:headers) { { 'content-disposition' => file } } diff --git a/spec/models/concerns/status_threading_concern_spec.rb b/spec/models/concerns/status_threading_concern_spec.rb index 50286ef77b9..2eac1ca6e5c 100644 --- a/spec/models/concerns/status_threading_concern_spec.rb +++ b/spec/models/concerns/status_threading_concern_spec.rb @@ -8,40 +8,40 @@ describe StatusThreadingConcern do let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } let!(:jeff) { Fabricate(:account, username: 'jeff') } let!(:status) { Fabricate(:status, account: alice) } - let!(:reply1) { Fabricate(:status, thread: status, account: jeff) } - let!(:reply2) { Fabricate(:status, thread: reply1, account: bob) } - let!(:reply3) { Fabricate(:status, thread: reply2, 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(reply3.ancestors(4)).to include(status, reply1, reply2) + 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 - reply1.update(visibility: :private) + reply_to_status.update(visibility: :private) status.update(visibility: :direct) - expect(reply3.ancestors(4, viewer)).to_not include(reply1, status) + 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(reply3.ancestors(4, viewer)).to_not include(reply1) + 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(reply3.ancestors(4, viewer)).to_not include(reply1) + 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(reply3.ancestors(4, viewer)).to_not include(reply1) + 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(reply3.ancestors(4, viewer)).to_not include(reply2) + expect(reply_to_second_reply.ancestors(4, viewer)).to_not include(reply_to_first_reply) end it 'ignores deleted records' do @@ -83,40 +83,40 @@ describe StatusThreadingConcern do let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } let!(:jeff) { Fabricate(:account, username: 'jeff') } let!(:status) { Fabricate(:status, account: alice) } - let!(:reply1) { Fabricate(:status, thread: status, account: alice) } - let!(:reply2) { Fabricate(:status, thread: status, account: bob) } - let!(:reply3) { Fabricate(:status, thread: reply1, account: jeff) } + 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(reply1, reply2, reply3) + 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 - reply1.update(visibility: :private) - reply3.update(visibility: :direct) + 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(reply1, reply3) + 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(reply3) + 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(reply3) + 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(reply3) + 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(reply2) + 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 diff --git a/spec/models/conversation_mute_spec.rb b/spec/models/conversation_mute_spec.rb deleted file mode 100644 index 3fc2915d4f3..00000000000 --- a/spec/models/conversation_mute_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe ConversationMute, type: :model do -end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 8b5e4fdaf7f..c1d6659aa7b 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Conversation, type: :model do +RSpec.describe Conversation do describe '#local?' do it 'returns true when URI is nil' do expect(Fabricate(:conversation).local?).to be true diff --git a/spec/models/custom_emoji_category_spec.rb b/spec/models/custom_emoji_category_spec.rb index 160033f4d48..30de07bd81c 100644 --- a/spec/models/custom_emoji_category_spec.rb +++ b/spec/models/custom_emoji_category_spec.rb @@ -1,5 +1,14 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe CustomEmojiCategory, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +describe CustomEmojiCategory do + describe 'validations' do + it 'validates name presence' do + record = described_class.new(name: nil) + + expect(record).to_not be_valid + expect(record).to model_have_error_on_field(:name) + end + end end diff --git a/spec/models/custom_emoji_filter_spec.rb b/spec/models/custom_emoji_filter_spec.rb index 30f0ec2b23d..c36fecd60d4 100644 --- a/spec/models/custom_emoji_filter_spec.rb +++ b/spec/models/custom_emoji_filter_spec.rb @@ -6,48 +6,48 @@ RSpec.describe CustomEmojiFilter do describe '#results' do subject { described_class.new(params).results } - let!(:custom_emoji_0) { Fabricate(:custom_emoji, domain: 'a') } - let!(:custom_emoji_1) { Fabricate(:custom_emoji, domain: 'b') } - let!(:custom_emoji_2) { Fabricate(:custom_emoji, domain: nil, shortcode: 'hoge') } + let!(:custom_emoji_domain_a) { Fabricate(:custom_emoji, domain: 'a') } + let!(:custom_emoji_domain_b) { Fabricate(:custom_emoji, domain: 'b') } + let!(:custom_emoji_domain_nil) { Fabricate(:custom_emoji, domain: nil, shortcode: 'hoge') } - context 'params have values' do - context 'local' do + context 'when params have values' do + context 'when local' do let(:params) { { local: true } } it 'returns ActiveRecord::Relation' do expect(subject).to be_a(ActiveRecord::Relation) - expect(subject).to match_array([custom_emoji_2]) + expect(subject).to contain_exactly(custom_emoji_domain_nil) end end - context 'remote' do + context 'when remote' do let(:params) { { remote: true } } it 'returns ActiveRecord::Relation' do expect(subject).to be_a(ActiveRecord::Relation) - expect(subject).to match_array([custom_emoji_0, custom_emoji_1]) + expect(subject).to contain_exactly(custom_emoji_domain_a, custom_emoji_domain_b) end end - context 'by_domain' do + context 'with by_domain' do let(:params) { { by_domain: 'a' } } it 'returns ActiveRecord::Relation' do expect(subject).to be_a(ActiveRecord::Relation) - expect(subject).to match_array([custom_emoji_0]) + expect(subject).to contain_exactly(custom_emoji_domain_a) end end - context 'shortcode' do + context 'when shortcode' do let(:params) { { shortcode: 'hoge' } } it 'returns ActiveRecord::Relation' do expect(subject).to be_a(ActiveRecord::Relation) - expect(subject).to match_array([custom_emoji_2]) + expect(subject).to contain_exactly(custom_emoji_domain_nil) end end - context 'else' do + context 'when some other case' do let(:params) { { else: 'else' } } it 'raises Mastodon::InvalidParameterError' do @@ -58,12 +58,12 @@ RSpec.describe CustomEmojiFilter do end end - context 'params without value' do + context 'when params without value' do let(:params) { { hoge: nil } } it 'returns ActiveRecord::Relation' do expect(subject).to be_a(ActiveRecord::Relation) - expect(subject).to match_array([custom_emoji_0, custom_emoji_1, custom_emoji_2]) + expect(subject).to contain_exactly(custom_emoji_domain_a, custom_emoji_domain_b, custom_emoji_domain_nil) end end end diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb index f6fcd468bc5..8a6487c3211 100644 --- a/spec/models/custom_emoji_spec.rb +++ b/spec/models/custom_emoji_spec.rb @@ -1,12 +1,14 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe CustomEmoji, type: :model do +RSpec.describe CustomEmoji do describe '#search' do subject { described_class.search(search_term) } let(:custom_emoji) { Fabricate(:custom_emoji, shortcode: shortcode) } - context 'shortcode is exact' do + context 'when shortcode is exact' do let(:shortcode) { 'blobpats' } let(:search_term) { 'blobpats' } @@ -15,7 +17,7 @@ RSpec.describe CustomEmoji, type: :model do end end - context 'shortcode is partial' do + context 'when shortcode is partial' do let(:shortcode) { 'blobpats' } let(:search_term) { 'blob' } @@ -30,7 +32,7 @@ RSpec.describe CustomEmoji, type: :model do let(:custom_emoji) { Fabricate(:custom_emoji, domain: domain) } - context 'domain is nil' do + context 'when domain is nil' do let(:domain) { nil } it 'returns true' do @@ -38,7 +40,7 @@ RSpec.describe CustomEmoji, type: :model do end end - context 'domain is present' do + context 'when domain is present' do let(:domain) { 'example.com' } it 'returns false' do diff --git a/spec/models/custom_filter_keyword_spec.rb b/spec/models/custom_filter_keyword_spec.rb deleted file mode 100644 index e15b9dad507..00000000000 --- a/spec/models/custom_filter_keyword_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe CustomFilterKeyword, type: :model do -end diff --git a/spec/models/custom_filter_spec.rb b/spec/models/custom_filter_spec.rb deleted file mode 100644 index 3943dd5f1a2..00000000000 --- a/spec/models/custom_filter_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe CustomFilter, type: :model do -end diff --git a/spec/models/device_spec.rb b/spec/models/device_spec.rb deleted file mode 100644 index 307552e9130..00000000000 --- a/spec/models/device_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe Device, type: :model do -end diff --git a/spec/models/domain_allow_spec.rb b/spec/models/domain_allow_spec.rb index e65435127d5..49e16376eaf 100644 --- a/spec/models/domain_allow_spec.rb +++ b/spec/models/domain_allow_spec.rb @@ -1,5 +1,18 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe DomainAllow, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +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') } + + it 'returns the correct records' do + results = described_class.matches_domain('example.com') + + expect(results).to eq([domain]) + end + end + end end diff --git a/spec/models/domain_block_spec.rb b/spec/models/domain_block_spec.rb index d1d57c16779..d595441fd30 100644 --- a/spec/models/domain_block_spec.rb +++ b/spec/models/domain_block_spec.rb @@ -1,12 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe DomainBlock, type: :model do +RSpec.describe DomainBlock do describe 'validations' do - it 'has a valid fabricator' do - domain_block = Fabricate.build(:domain_block) - expect(domain_block).to be_valid - end - it 'is invalid without a domain' do domain_block = Fabricate.build(:domain_block, domain: nil) domain_block.valid? @@ -14,84 +11,102 @@ RSpec.describe DomainBlock, type: :model 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 describe '.blocked?' do it 'returns true if the domain is suspended' do Fabricate(:domain_block, domain: 'example.com', severity: :suspend) - expect(DomainBlock.blocked?('example.com')).to be true + expect(described_class.blocked?('example.com')).to be true end it 'returns false even if the domain is silenced' do Fabricate(:domain_block, domain: 'example.com', severity: :silence) - expect(DomainBlock.blocked?('example.com')).to be false + expect(described_class.blocked?('example.com')).to be false end it 'returns false if the domain is not suspended nor silenced' do - expect(DomainBlock.blocked?('example.com')).to be false + expect(described_class.blocked?('example.com')).to be false end end describe '.rule_for' do it 'returns rule matching a blocked domain' do block = Fabricate(:domain_block, domain: 'example.com') - expect(DomainBlock.rule_for('example.com')).to eq block + expect(described_class.rule_for('example.com')).to eq block end it 'returns a rule matching a subdomain of a blocked domain' do block = Fabricate(:domain_block, domain: 'example.com') - expect(DomainBlock.rule_for('sub.example.com')).to eq block + expect(described_class.rule_for('sub.example.com')).to eq block end it 'returns a rule matching a blocked subdomain' do block = Fabricate(:domain_block, domain: 'sub.example.com') - expect(DomainBlock.rule_for('sub.example.com')).to eq block + expect(described_class.rule_for('sub.example.com')).to eq block end it 'returns a rule matching a blocked TLD' do block = Fabricate(:domain_block, domain: 'google') - expect(DomainBlock.rule_for('google')).to eq block + expect(described_class.rule_for('google')).to eq block end it 'returns a rule matching a subdomain of a blocked TLD' do block = Fabricate(:domain_block, domain: 'google') - expect(DomainBlock.rule_for('maps.google')).to eq block + expect(described_class.rule_for('maps.google')).to eq block end end describe '#stricter_than?' do it 'returns true if the new block has suspend severity while the old has lower severity' do - suspend = DomainBlock.new(domain: 'domain', severity: :suspend) - silence = DomainBlock.new(domain: 'domain', severity: :silence) - noop = DomainBlock.new(domain: 'domain', severity: :noop) + suspend = described_class.new(domain: 'domain', severity: :suspend) + silence = described_class.new(domain: 'domain', severity: :silence) + noop = described_class.new(domain: 'domain', severity: :noop) expect(suspend.stricter_than?(silence)).to be true expect(suspend.stricter_than?(noop)).to be true end it 'returns false if the new block has lower severity than the old one' do - suspend = DomainBlock.new(domain: 'domain', severity: :suspend) - silence = DomainBlock.new(domain: 'domain', severity: :silence) - noop = DomainBlock.new(domain: 'domain', severity: :noop) + suspend = described_class.new(domain: 'domain', severity: :suspend) + silence = described_class.new(domain: 'domain', severity: :silence) + noop = described_class.new(domain: 'domain', severity: :noop) expect(silence.stricter_than?(suspend)).to be false expect(noop.stricter_than?(suspend)).to be false expect(noop.stricter_than?(silence)).to be false end it 'returns false if the new block does is less strict regarding reports' do - older = DomainBlock.new(domain: 'domain', severity: :silence, reject_reports: true) - newer = DomainBlock.new(domain: 'domain', severity: :silence, reject_reports: false) + older = described_class.new(domain: 'domain', severity: :silence, reject_reports: true) + newer = described_class.new(domain: 'domain', severity: :silence, reject_reports: false) expect(newer.stricter_than?(older)).to be false end it 'returns false if the new block does is less strict regarding media' do - older = DomainBlock.new(domain: 'domain', severity: :silence, reject_media: true) - newer = DomainBlock.new(domain: 'domain', severity: :silence, reject_media: false) + older = described_class.new(domain: 'domain', severity: :silence, reject_media: true) + newer = described_class.new(domain: 'domain', severity: :silence, reject_media: false) expect(newer.stricter_than?(older)).to be false end end + + describe '#public_domain' do + context 'with a domain block that is obfuscated' do + let(:domain_block) { Fabricate(:domain_block, domain: 'hostname.example.com', obfuscate: true) } + + it 'garbles the domain' do + expect(domain_block.public_domain).to eq 'hostna**.******e.com' + end + end + + context 'with a domain block that is not obfuscated' do + let(:domain_block) { Fabricate(:domain_block, domain: 'example.com', obfuscate: false) } + + it 'returns the domain value' do + expect(domain_block.public_domain).to eq 'example.com' + end + end + end end diff --git a/spec/models/email_domain_block_spec.rb b/spec/models/email_domain_block_spec.rb index e23116888c7..5874c5e53c4 100644 --- a/spec/models/email_domain_block_spec.rb +++ b/spec/models/email_domain_block_spec.rb @@ -1,34 +1,29 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe EmailDomainBlock, type: :model do - describe 'validations' do - it 'has a valid fabricator' do - email_domain_block = Fabricate.build(:email_domain_block) - expect(email_domain_block).to be_valid - end - end - +RSpec.describe EmailDomainBlock do describe 'block?' do let(:input) { nil } - context 'given an e-mail address' do + context 'when given an e-mail address' do let(:input) { "foo@#{domain}" } - context do + context 'with a top level domain' do let(:domain) { 'example.com' } it 'returns true if the domain is blocked' do Fabricate(:email_domain_block, domain: 'example.com') - expect(EmailDomainBlock.block?(input)).to be true + expect(described_class.block?(input)).to be true end it 'returns false if the domain is not blocked' do Fabricate(:email_domain_block, domain: 'other-example.com') - expect(EmailDomainBlock.block?(input)).to be false + expect(described_class.block?(input)).to be false end end - context do + context 'with a subdomain' do let(:domain) { 'mail.example.com' } it 'returns true if it is a subdomain of a blocked domain' do @@ -38,12 +33,12 @@ RSpec.describe EmailDomainBlock, type: :model do end end - context 'given an array of domains' do + context 'when given an array of domains' do let(:input) { %w(foo.com mail.foo.com) } it 'returns true if the domain is blocked' do Fabricate(:email_domain_block, domain: 'mail.foo.com') - expect(EmailDomainBlock.block?(input)).to be true + expect(described_class.block?(input)).to be true end end end diff --git a/spec/models/encrypted_message_spec.rb b/spec/models/encrypted_message_spec.rb deleted file mode 100644 index 64f9c6912ae..00000000000 --- a/spec/models/encrypted_message_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe EncryptedMessage, type: :model do -end diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index 5202ae9e175..75468898d27 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe Export do @@ -8,9 +10,9 @@ describe Export do describe 'to_csv' do it 'returns a csv of the blocked accounts' do - target_accounts.each(&account.method(:block!)) + target_accounts.each { |target_account| account.block!(target_account) } - export = Export.new(account).to_blocked_accounts_csv + export = described_class.new(account).to_blocked_accounts_csv results = export.strip.split expect(results.size).to eq 2 @@ -18,9 +20,9 @@ describe Export do end it 'returns a csv of the muted accounts' do - target_accounts.each(&account.method(:mute!)) + target_accounts.each { |target_account| account.mute!(target_account) } - export = Export.new(account).to_muted_accounts_csv + export = described_class.new(account).to_muted_accounts_csv results = export.strip.split("\n") expect(results.size).to eq 3 @@ -29,9 +31,9 @@ describe Export do end it 'returns a csv of the following accounts' do - target_accounts.each(&account.method(:follow!)) + target_accounts.each { |target_account| account.follow!(target_account) } - export = Export.new(account).to_following_accounts_csv + export = described_class.new(account).to_following_accounts_csv results = export.strip.split("\n") expect(results.size).to eq 3 @@ -43,24 +45,24 @@ describe Export do describe 'total_storage' do it 'returns the total size of the media attachments' do media_attachment = Fabricate(:media_attachment, account: account) - expect(Export.new(account).total_storage).to eq media_attachment.file_file_size || 0 + expect(described_class.new(account).total_storage).to eq media_attachment.file_file_size || 0 end end describe 'total_follows' do it 'returns the total number of the followed accounts' do - target_accounts.each(&account.method(:follow!)) - expect(Export.new(account.reload).total_follows).to eq 2 + target_accounts.each { |target_account| account.follow!(target_account) } + expect(described_class.new(account.reload).total_follows).to eq 2 end it 'returns the total number of the blocked accounts' do - target_accounts.each(&account.method(:block!)) - expect(Export.new(account.reload).total_blocks).to eq 2 + target_accounts.each { |target_account| account.block!(target_account) } + expect(described_class.new(account.reload).total_blocks).to eq 2 end it 'returns the total number of the muted accounts' do - target_accounts.each(&account.method(:mute!)) - expect(Export.new(account.reload).total_mutes).to eq 2 + target_accounts.each { |target_account| account.mute!(target_account) } + expect(described_class.new(account.reload).total_mutes).to eq 2 end end end diff --git a/spec/models/extended_description_spec.rb b/spec/models/extended_description_spec.rb new file mode 100644 index 00000000000..ecc27c0f6dd --- /dev/null +++ b/spec/models/extended_description_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ExtendedDescription do + describe '.current' do + context 'with the default values' do + it 'makes a new instance' do + record = described_class.current + + expect(record.text).to be_nil + expect(record.updated_at).to be_nil + end + end + + context 'with a custom setting value' do + before do + setting = instance_double(Setting, value: 'Extended text', updated_at: 10.days.ago) + allow(Setting).to receive(:find_by).with(var: 'site_extended_description').and_return(setting) + end + + it 'has the privacy text' do + record = described_class.current + + expect(record.text).to eq('Extended text') + end + end + end +end diff --git a/spec/models/favourite_spec.rb b/spec/models/favourite_spec.rb index f755590ee50..ef7fbdefcd4 100644 --- a/spec/models/favourite_spec.rb +++ b/spec/models/favourite_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Favourite, type: :model do +RSpec.describe Favourite do let(:account) { Fabricate(:account) } context 'when status is a reblog' do @@ -8,12 +10,12 @@ RSpec.describe Favourite, type: :model do let(:status) { Fabricate(:status, reblog: reblog) } it 'invalidates if the reblogged status is already a favourite' do - Favourite.create!(account: account, status: reblog) - expect(Favourite.new(account: account, status: status).valid?).to be false + described_class.create!(account: account, status: reblog) + expect(described_class.new(account: account, status: status).valid?).to be false end it 'replaces status with the reblogged one if it is a reblog' do - favourite = Favourite.create!(account: account, status: status) + favourite = described_class.create!(account: account, status: status) expect(favourite.status).to eq reblog end end @@ -22,7 +24,7 @@ RSpec.describe Favourite, type: :model do let(:status) { Fabricate(:status, reblog: nil) } it 'saves with the specified status' do - favourite = Favourite.create!(account: account, status: status) + favourite = described_class.create!(account: account, status: status) expect(favourite.status).to eq status end end diff --git a/spec/models/featured_tag_spec.rb b/spec/models/featured_tag_spec.rb deleted file mode 100644 index 07533e0b90c..00000000000 --- a/spec/models/featured_tag_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe FeaturedTag, type: :model 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 39107a2b049..00000000000 --- a/spec/models/follow_recommendation_suppression_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe FollowRecommendationSuppression, type: :model do -end diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb index 901eabc9df4..e413747852f 100644 --- a/spec/models/follow_request_spec.rb +++ b/spec/models/follow_request_spec.rb @@ -1,14 +1,30 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe FollowRequest, type: :model do +RSpec.describe FollowRequest do describe '#authorize!' do - let(:follow_request) { Fabricate(:follow_request, account: account, target_account: target_account) } - let(:account) { Fabricate(:account) } - let(:target_account) { Fabricate(:account) } + let!(:follow_request) { Fabricate(:follow_request, account: account, target_account: target_account) } + let(:account) { Fabricate(:account) } + let(:target_account) { Fabricate(:account) } + + context 'when the to-be-followed person has been added to a list' do + let!(:list) { Fabricate(:list, account: account) } + + before do + list.accounts << target_account + end + + it 'updates the ListAccount' do + expect { follow_request.authorize! }.to change { [list.list_accounts.first.follow_request_id, list.list_accounts.first.follow_id] }.from([follow_request.id, nil]).to([nil, anything]) + end + 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) - expect(MergeWorker).to receive(:perform_async).with(target_account.id, account.id) + expect(account).to receive(:follow!).with(target_account, reblogs: true, notify: false, uri: follow_request.uri, languages: nil, bypass_limit: true) 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!) follow_request.authorize! end @@ -27,4 +43,22 @@ RSpec.describe FollowRequest, type: :model do expect(follow_request.account.muting_reblogs?(target)).to be true end end + + describe '#reject!' do + let!(:follow_request) { Fabricate(:follow_request, account: account, target_account: target_account) } + let(:account) { Fabricate(:account) } + let(:target_account) { Fabricate(:account) } + + context 'when the to-be-followed person has been added to a list' do + let!(:list) { Fabricate(:list, account: account) } + + before do + list.accounts << target_account + end + + it 'deletes the ListAccount record' do + expect { follow_request.reject! }.to change { list.accounts.count }.from(1).to(0) + end + end + end end diff --git a/spec/models/follow_spec.rb b/spec/models/follow_spec.rb index e723a1ef211..c7743183cc6 100644 --- a/spec/models/follow_spec.rb +++ b/spec/models/follow_spec.rb @@ -1,16 +1,13 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Follow, type: :model do +RSpec.describe Follow do let(:alice) { Fabricate(:account, username: 'alice') } let(:bob) { Fabricate(:account, username: 'bob') } describe 'validations' do - subject { Follow.new(account: alice, target_account: bob, rate_limit: true) } - - it 'has a valid fabricator' do - follow = Fabricate.build(:follow) - expect(follow).to be_valid - end + subject { described_class.new(account: alice, target_account: bob, rate_limit: true) } it 'is invalid without an account' do follow = Fabricate.build(:follow, account: nil) @@ -41,10 +38,10 @@ RSpec.describe Follow, type: :model do describe 'recent' do it 'sorts so that more recent follows comes earlier' do - follow0 = Follow.create!(account: alice, target_account: bob) - follow1 = Follow.create!(account: bob, target_account: alice) + follow0 = described_class.create!(account: alice, target_account: bob) + follow1 = described_class.create!(account: bob, target_account: alice) - a = Follow.recent.to_a + a = described_class.recent.to_a expect(a.size).to eq 2 expect(a[0]).to eq follow1 diff --git a/spec/models/form/account_batch_spec.rb b/spec/models/form/account_batch_spec.rb new file mode 100644 index 00000000000..fd8e9090106 --- /dev/null +++ b/spec/models/form/account_batch_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Form::AccountBatch do + let(:account_batch) { described_class.new } + + describe '#save' do + subject { account_batch.save } + + let(:account) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:account_ids) { [] } + let(:query) { Account.none } + + before do + account_batch.assign_attributes( + action: action, + current_account: account, + account_ids: account_ids, + query: query, + select_all_matching: select_all_matching + ) + end + + context 'when action is "suspend"' do + let(:action) { 'suspend' } + + let(:target_account) { Fabricate(:account) } + let(:target_account2) { Fabricate(:account) } + + before do + Fabricate(:report, target_account: target_account) + Fabricate(:report, target_account: target_account2) + end + + context 'when accounts are passed as account_ids' 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) + end + end + + context 'when accounts are passed as a query' 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]) + 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 + end + end + end +end diff --git a/spec/models/form/admin_settings_spec.rb b/spec/models/form/admin_settings_spec.rb new file mode 100644 index 00000000000..0dc2d881ad2 --- /dev/null +++ b/spec/models/form/admin_settings_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Form::AdminSettings do + describe 'validations' do + describe 'site_contact_username' do + context 'with no accounts' do + it 'is not valid' do + setting = described_class.new(site_contact_username: 'Test') + setting.valid? + + expect(setting).to model_have_error_on_field(:site_contact_username) + end + end + + context 'with an account' do + before { Fabricate(:account, username: 'Glorp') } + + it 'is not valid when account doesnt match' do + setting = described_class.new(site_contact_username: 'Test') + setting.valid? + + expect(setting).to model_have_error_on_field(:site_contact_username) + end + + it 'is valid when account matches' do + setting = described_class.new(site_contact_username: 'Glorp') + setting.valid? + + expect(setting).to_not model_have_error_on_field(:site_contact_username) + end + end + end + end +end diff --git a/spec/models/form/import_spec.rb b/spec/models/form/import_spec.rb new file mode 100644 index 00000000000..2b70e396b9f --- /dev/null +++ b/spec/models/form/import_spec.rb @@ -0,0 +1,318 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Form::Import do + subject { described_class.new(current_account: account, type: import_type, mode: import_mode, data: data) } + + let(:account) { Fabricate(:account) } + let(:data) { fixture_file_upload(import_file) } + let(:import_mode) { 'merge' } + + describe 'validations' do + shared_examples 'incompatible import type' do |type, file| + let(:import_file) { file } + let(:import_type) { type } + + it 'has errors' do + subject.validate + expect(subject.errors[:data]).to include(I18n.t('imports.errors.incompatible_type')) + end + end + + shared_examples 'too many CSV rows' do |type, file, allowed_rows| + let(:import_file) { file } + let(:import_type) { type } + + before do + stub_const 'Form::Import::ROWS_PROCESSING_LIMIT', allowed_rows + end + + it 'has errors' do + subject.validate + expect(subject.errors[:data]).to include(I18n.t('imports.errors.over_rows_processing_limit', count: Form::Import::ROWS_PROCESSING_LIMIT)) + end + end + + shared_examples 'valid import' do |type, file| + let(:import_file) { file } + let(:import_type) { type } + + it 'passes validation' do + expect(subject).to be_valid + end + end + + context 'when the file too large' do + let(:import_type) { 'following' } + let(:import_file) { 'imports.txt' } + + before do + stub_const 'Form::Import::FILE_SIZE_LIMIT', 5 + end + + it 'has errors' do + subject.validate + expect(subject.errors[:data]).to include(I18n.t('imports.errors.too_large')) + end + end + + context 'when the CSV file is malformed CSV' do + let(:import_type) { 'following' } + let(:import_file) { 'boop.ogg' } + + it 'has errors' do + # NOTE: not testing more specific error because we don't know the string to match + expect(subject).to model_have_error_on_field(:data) + end + end + + context 'when importing more follows than allowed' do + let(:import_type) { 'following' } + let(:import_file) { 'imports.txt' } + + before do + allow(FollowLimitValidator).to receive(:limit_for_account).with(account).and_return(1) + end + + it 'has errors' do + subject.validate + expect(subject.errors[:data]).to include(I18n.t('users.follow_limit_reached', limit: 1)) + end + end + + it_behaves_like 'too many CSV rows', 'following', 'imports.txt', 1 + it_behaves_like 'too many CSV rows', 'blocking', 'imports.txt', 1 + it_behaves_like 'too many CSV rows', 'muting', 'imports.txt', 1 + it_behaves_like 'too many CSV rows', 'domain_blocking', 'domain_blocks.csv', 2 + it_behaves_like 'too many CSV rows', 'bookmarks', 'bookmark-imports.txt', 3 + it_behaves_like 'too many CSV rows', 'lists', 'lists.csv', 2 + + # Importing list of addresses with no headers into various types + it_behaves_like 'valid import', 'following', 'imports.txt' + it_behaves_like 'valid import', 'blocking', 'imports.txt' + it_behaves_like 'valid import', 'muting', 'imports.txt' + + # Importing domain blocks with headers into expected type + it_behaves_like 'valid import', 'domain_blocking', 'domain_blocks.csv' + + # Importing bookmarks list with no headers into expected type + it_behaves_like 'valid import', 'bookmarks', 'bookmark-imports.txt' + + # Importing lists with no headers into expected type + it_behaves_like 'valid import', 'lists', 'lists.csv' + + # Importing followed accounts with headers into various compatible types + it_behaves_like 'valid import', 'following', 'following_accounts.csv' + it_behaves_like 'valid import', 'blocking', 'following_accounts.csv' + it_behaves_like 'valid import', 'muting', 'following_accounts.csv' + + # Importing domain blocks with headers into incompatible types + it_behaves_like 'incompatible import type', 'following', 'domain_blocks.csv' + it_behaves_like 'incompatible import type', 'blocking', 'domain_blocks.csv' + it_behaves_like 'incompatible import type', 'muting', 'domain_blocks.csv' + it_behaves_like 'incompatible import type', 'bookmarks', 'domain_blocks.csv' + + # Importing followed accounts with headers into incompatible types + it_behaves_like 'incompatible import type', 'domain_blocking', 'following_accounts.csv' + it_behaves_like 'incompatible import type', 'bookmarks', 'following_accounts.csv' + end + + describe '#guessed_type' do + shared_examples 'with enough information' do |type, file, original_filename, expected_guess| + let(:import_file) { file } + let(:import_type) { type } + + before do + allow(data).to receive(:original_filename).and_return(original_filename) + end + + it 'guesses the expected type' do + expect(subject.guessed_type).to eq expected_guess + end + end + + context 'when the headers are enough to disambiguate' do + it_behaves_like 'with enough information', 'following', 'following_accounts.csv', 'import.csv', :following + it_behaves_like 'with enough information', 'blocking', 'following_accounts.csv', 'import.csv', :following + it_behaves_like 'with enough information', 'muting', 'following_accounts.csv', 'import.csv', :following + + it_behaves_like 'with enough information', 'following', 'muted_accounts.csv', 'imports.csv', :muting + it_behaves_like 'with enough information', 'blocking', 'muted_accounts.csv', 'imports.csv', :muting + it_behaves_like 'with enough information', 'muting', 'muted_accounts.csv', 'imports.csv', :muting + end + + context 'when the file name is enough to disambiguate' do + it_behaves_like 'with enough information', 'following', 'imports.txt', 'following_accounts.csv', :following + it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'following_accounts.csv', :following + it_behaves_like 'with enough information', 'muting', 'imports.txt', 'following_accounts.csv', :following + + it_behaves_like 'with enough information', 'following', 'imports.txt', 'follows.csv', :following + it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'follows.csv', :following + it_behaves_like 'with enough information', 'muting', 'imports.txt', 'follows.csv', :following + + it_behaves_like 'with enough information', 'following', 'imports.txt', 'blocked_accounts.csv', :blocking + it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'blocked_accounts.csv', :blocking + it_behaves_like 'with enough information', 'muting', 'imports.txt', 'blocked_accounts.csv', :blocking + + it_behaves_like 'with enough information', 'following', 'imports.txt', 'blocks.csv', :blocking + it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'blocks.csv', :blocking + it_behaves_like 'with enough information', 'muting', 'imports.txt', 'blocks.csv', :blocking + + it_behaves_like 'with enough information', 'following', 'imports.txt', 'muted_accounts.csv', :muting + it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'muted_accounts.csv', :muting + it_behaves_like 'with enough information', 'muting', 'imports.txt', 'muted_accounts.csv', :muting + + it_behaves_like 'with enough information', 'following', 'imports.txt', 'mutes.csv', :muting + it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'mutes.csv', :muting + it_behaves_like 'with enough information', 'muting', 'imports.txt', 'mutes.csv', :muting + end + end + + describe '#likely_mismatched?' do + shared_examples 'with matching types' do |type, file, original_filename = nil| + let(:import_file) { file } + let(:import_type) { type } + + before do + allow(data).to receive(:original_filename).and_return(original_filename) if original_filename.present? + end + + it 'returns false' do + expect(subject.likely_mismatched?).to be false + end + end + + shared_examples 'with mismatching types' do |type, file, original_filename = nil| + let(:import_file) { file } + let(:import_type) { type } + + before do + allow(data).to receive(:original_filename).and_return(original_filename) if original_filename.present? + end + + it 'returns true' do + expect(subject.likely_mismatched?).to be true + end + end + + it_behaves_like 'with matching types', 'following', 'following_accounts.csv' + it_behaves_like 'with matching types', 'following', 'following_accounts.csv', 'imports.txt' + it_behaves_like 'with matching types', 'following', 'imports.txt' + it_behaves_like 'with matching types', 'blocking', 'imports.txt', 'blocks.csv' + it_behaves_like 'with matching types', 'blocking', 'imports.txt' + it_behaves_like 'with matching types', 'muting', 'muted_accounts.csv' + it_behaves_like 'with matching types', 'muting', 'muted_accounts.csv', 'imports.txt' + it_behaves_like 'with matching types', 'muting', 'imports.txt' + it_behaves_like 'with matching types', 'domain_blocking', 'domain_blocks.csv' + it_behaves_like 'with matching types', 'domain_blocking', 'domain_blocks.csv', 'imports.txt' + it_behaves_like 'with matching types', 'bookmarks', 'bookmark-imports.txt' + it_behaves_like 'with matching types', 'bookmarks', 'bookmark-imports.txt', 'imports.txt' + + it_behaves_like 'with mismatching types', 'following', 'imports.txt', 'blocks.csv' + it_behaves_like 'with mismatching types', 'following', 'imports.txt', 'blocked_accounts.csv' + it_behaves_like 'with mismatching types', 'following', 'imports.txt', 'mutes.csv' + it_behaves_like 'with mismatching types', 'following', 'imports.txt', 'muted_accounts.csv' + it_behaves_like 'with mismatching types', 'following', 'muted_accounts.csv' + it_behaves_like 'with mismatching types', 'following', 'muted_accounts.csv', 'imports.txt' + it_behaves_like 'with mismatching types', 'blocking', 'following_accounts.csv' + it_behaves_like 'with mismatching types', 'blocking', 'following_accounts.csv', 'imports.txt' + it_behaves_like 'with mismatching types', 'blocking', 'muted_accounts.csv' + it_behaves_like 'with mismatching types', 'blocking', 'muted_accounts.csv', 'imports.txt' + it_behaves_like 'with mismatching types', 'blocking', 'imports.txt', 'follows.csv' + it_behaves_like 'with mismatching types', 'blocking', 'imports.txt', 'following_accounts.csv' + it_behaves_like 'with mismatching types', 'blocking', 'imports.txt', 'mutes.csv' + it_behaves_like 'with mismatching types', 'blocking', 'imports.txt', 'muted_accounts.csv' + it_behaves_like 'with mismatching types', 'muting', 'following_accounts.csv' + it_behaves_like 'with mismatching types', 'muting', 'following_accounts.csv', 'imports.txt' + it_behaves_like 'with mismatching types', 'muting', 'imports.txt', 'follows.csv' + it_behaves_like 'with mismatching types', 'muting', 'imports.txt', 'following_accounts.csv' + it_behaves_like 'with mismatching types', 'muting', 'imports.txt', 'blocks.csv' + it_behaves_like 'with mismatching types', 'muting', 'imports.txt', 'blocked_accounts.csv' + end + + describe 'save' do + shared_examples 'on successful import' do |type, mode, file, expected_rows| + let(:import_type) { type } + let(:import_file) { file } + let(:import_mode) { mode } + + before do + subject.save + end + + it 'creates the expected rows' do + expect(account.bulk_imports.first.rows.pluck(:data)).to match_array(expected_rows) + end + + context 'with a BulkImport' do + let(:bulk_import) { account.bulk_imports.first } + + it 'creates a non-nil bulk import' do + expect(bulk_import).to_not be_nil + end + + it 'matches the subjects type' do + expect(bulk_import.type.to_sym).to eq subject.type.to_sym + end + + it 'matches the subjects original filename' do + expect(bulk_import.original_filename).to eq subject.data.original_filename + end + + it 'matches the subjects likely_mismatched? value' do + expect(bulk_import.likely_mismatched?).to eq subject.likely_mismatched? + end + + it 'matches the subject overwrite value' do + expect(bulk_import.overwrite?).to eq !!subject.overwrite # rubocop:disable Style/DoubleNegation + end + + it 'has zero processed items' do + expect(bulk_import.processed_items).to eq 0 + end + + it 'has zero imported items' do + expect(bulk_import.imported_items).to eq 0 + end + + it 'has a correct total_items value' do + expect(bulk_import.total_items).to eq bulk_import.rows.count + end + + it 'defaults to unconfirmed true' do + expect(bulk_import.unconfirmed?).to be true + end + end + end + + it_behaves_like 'on successful import', 'following', 'merge', 'imports.txt', (%w(user@example.com user@test.com).map { |acct| { 'acct' => acct } }) + it_behaves_like 'on successful import', 'following', 'overwrite', 'imports.txt', (%w(user@example.com user@test.com).map { |acct| { 'acct' => acct } }) + it_behaves_like 'on successful import', 'blocking', 'merge', 'imports.txt', (%w(user@example.com user@test.com).map { |acct| { 'acct' => acct } }) + it_behaves_like 'on successful import', 'blocking', 'overwrite', 'imports.txt', (%w(user@example.com user@test.com).map { |acct| { 'acct' => acct } }) + it_behaves_like 'on successful import', 'muting', 'merge', 'imports.txt', (%w(user@example.com user@test.com).map { |acct| { 'acct' => acct } }) + it_behaves_like 'on successful import', 'domain_blocking', 'merge', 'domain_blocks.csv', (%w(bad.domain worse.domain reject.media).map { |domain| { 'domain' => domain } }) + it_behaves_like 'on successful import', 'bookmarks', 'merge', 'bookmark-imports.txt', (%w(https://example.com/statuses/1312 https://local.com/users/foo/statuses/42 https://unknown-remote.com/users/bar/statuses/1 https://example.com/statuses/direct).map { |uri| { 'uri' => uri } }) + + 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'] }, + ] + + it_behaves_like 'on successful import', 'muting', 'merge', 'muted_accounts.csv', [ + { 'acct' => 'user@example.com', 'hide_notifications' => true }, + { 'acct' => 'user@test.com', 'hide_notifications' => false }, + ] + + it_behaves_like 'on successful import', 'lists', 'merge', 'lists.csv', [ + { 'acct' => 'gargron@example.com', 'list_name' => 'Mastodon project' }, + { 'acct' => 'mastodon@example.com', 'list_name' => 'Mastodon project' }, + { 'acct' => 'foo@example.com', 'list_name' => 'test' }, + ] + + # Based on the bug report 20571 where UTF-8 encoded domains were rejecting import of their users + # + # https://github.com/mastodon/mastodon/issues/20571 + it_behaves_like 'on successful import', 'following', 'merge', 'utf8-followers.txt', [{ 'acct' => 'nare@թութ.հայ' }] + end +end diff --git a/spec/models/form/status_filter_batch_action_spec.rb b/spec/models/form/status_filter_batch_action_spec.rb new file mode 100644 index 00000000000..f06a11cc8b0 --- /dev/null +++ b/spec/models/form/status_filter_batch_action_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Form::StatusFilterBatchAction do + describe '#save!' do + it 'does nothing if status_filter_ids is empty' do + batch_action = described_class.new(status_filter_ids: []) + + expect(batch_action.save!).to be_nil + end + end +end diff --git a/spec/models/home_feed_spec.rb b/spec/models/home_feed_spec.rb index 196bef1e495..bd649d82693 100644 --- a/spec/models/home_feed_spec.rb +++ b/spec/models/home_feed_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe HomeFeed, type: :model do +RSpec.describe HomeFeed do subject { described_class.new(account) } let(:account) { Fabricate(:account) } diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb index 689c9b797f4..2fca1e1c14e 100644 --- a/spec/models/identity_spec.rb +++ b/spec/models/identity_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Identity, type: :model do +RSpec.describe Identity do describe '.find_for_oauth' do let(:auth) { Fabricate(:identity, user: Fabricate(:user)) } @@ -10,7 +12,7 @@ RSpec.describe Identity, type: :model do end it 'returns an instance of Identity' do - expect(described_class.find_for_oauth(auth)).to be_instance_of Identity + expect(described_class.find_for_oauth(auth)).to be_instance_of described_class end end end diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb index 4280b3237a0..3605f0b9bf8 100644 --- a/spec/models/import_spec.rb +++ b/spec/models/import_spec.rb @@ -1,33 +1,25 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Import, type: :model do +RSpec.describe Import do let(:account) { Fabricate(:account) } let(:type) { 'following' } let(:data) { attachment_fixture('imports.txt') } describe 'validations' do it 'has a valid parameters' do - import = Import.create(account: account, type: type, data: data) + import = described_class.create(account: account, type: type, data: data) expect(import).to be_valid end it 'is invalid without an type' do - import = Import.create(account: account, data: data) + import = described_class.create(account: account, data: data) expect(import).to model_have_error_on_field(:type) end it 'is invalid without a data' do - import = Import.create(account: account, type: type) - expect(import).to model_have_error_on_field(:data) - end - - it 'is invalid with too many rows in data' do - import = Import.create(account: account, type: type, data: StringIO.new("foo@bar.com\n" * (ImportService::ROWS_PROCESSING_LIMIT + 10))) - expect(import).to model_have_error_on_field(:data) - end - - it 'is invalid when there are more rows when following limit' do - import = Import.create(account: account, type: type, data: StringIO.new("foo@bar.com\n" * (FollowLimitValidator.limit_for_account(account) + 10))) + import = described_class.create(account: account, type: type) expect(import).to model_have_error_on_field(:data) end end diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb index b0596c56123..4ad589f2c7a 100644 --- a/spec/models/invite_spec.rb +++ b/spec/models/invite_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Invite, type: :model do +RSpec.describe Invite do describe '#valid_for_use?' do it 'returns true when there are no limitations' do invite = Fabricate(:invite, max_uses: nil, expires_at: nil) diff --git a/spec/models/ip_block_spec.rb b/spec/models/ip_block_spec.rb index 6603c6417aa..ed58826672e 100644 --- a/spec/models/ip_block_spec.rb +++ b/spec/models/ip_block_spec.rb @@ -1,5 +1,15 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe IpBlock, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +describe IpBlock do + describe 'to_log_human_identifier' do + let(:ip_block) { described_class.new(ip: '192.168.0.1') } + + it 'combines the IP and prefix into a string' do + result = ip_block.to_log_human_identifier + + expect(result).to eq('192.168.0.1/32') + end + end end diff --git a/spec/models/list_account_spec.rb b/spec/models/list_account_spec.rb deleted file mode 100644 index a0cf02efe25..00000000000 --- a/spec/models/list_account_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe ListAccount, type: :model do -end diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb deleted file mode 100644 index b780bb1de07..00000000000 --- a/spec/models/list_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe List, type: :model do -end diff --git a/spec/models/login_activity_spec.rb b/spec/models/login_activity_spec.rb deleted file mode 100644 index 12d8c436383..00000000000 --- a/spec/models/login_activity_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe LoginActivity, type: :model do -end diff --git a/spec/models/marker_spec.rb b/spec/models/marker_spec.rb index d716aa75c20..51dd584388d 100644 --- a/spec/models/marker_spec.rb +++ b/spec/models/marker_spec.rb @@ -1,5 +1,16 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Marker, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +describe Marker do + describe 'validations' do + describe 'timeline' do + it 'must be included in valid list' do + record = described_class.new(timeline: 'not real timeline') + + expect(record).to_not be_valid + expect(record).to model_have_error_on_field(:timeline) + end + end + end end diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb index 097c76f311f..6a82c8135a9 100644 --- a/spec/models/media_attachment_spec.rb +++ b/spec/models/media_attachment_spec.rb @@ -1,12 +1,14 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe MediaAttachment, type: :model do +RSpec.describe MediaAttachment, paperclip_processing: true 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 'remote_url is blank' do + context 'when remote_url is blank' do let(:remote_url) { '' } it 'returns true' do @@ -14,7 +16,7 @@ RSpec.describe MediaAttachment, type: :model do end end - context 'remote_url is present' do + context 'when remote_url is present' do let(:remote_url) { 'remote_url' } it 'returns false' do @@ -26,12 +28,12 @@ RSpec.describe MediaAttachment, type: :model 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 'file is blank' do + context 'when file is blank' do let(:file) { nil } - context 'remote_url is present' do + context 'when remote_url is present' do let(:remote_url) { 'remote_url' } it 'returns true' do @@ -40,10 +42,10 @@ RSpec.describe MediaAttachment, type: :model do end end - context 'file is present' do + context 'when file is present' do let(:file) { attachment_fixture('avatar.gif') } - context 'remote_url is blank' do + context 'when remote_url is blank' do let(:remote_url) { '' } it 'returns false' do @@ -51,7 +53,7 @@ RSpec.describe MediaAttachment, type: :model do end end - context 'remote_url is present' do + context 'when remote_url is present' do let(:remote_url) { 'remote_url' } it 'returns true' do @@ -62,11 +64,11 @@ RSpec.describe MediaAttachment, type: :model 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 @@ -75,31 +77,104 @@ RSpec.describe MediaAttachment, type: :model 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) { MediaAttachment.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 }, @@ -107,17 +182,11 @@ RSpec.describe MediaAttachment, type: :model do fixtures.each do |fixture| context fixture[:filename] do - let(:media) { MediaAttachment.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] @@ -127,7 +196,19 @@ RSpec.describe MediaAttachment, type: :model do end describe 'ogg with cover art' do - let(:media) { MediaAttachment.create(account: Fabricate(:account), file: attachment_fixture('boop.ogg')) } + let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.ogg')) } + + it 'sets correct file metadata' do + expect(media.type).to eq 'audio' + expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102) + expect(media.thumbnail.present?).to be true + expect(media.file.meta['colors']['background']).to eq '#3088d4' + expect(media.file_file_name).to_not eq 'boop.ogg' + end + end + + describe 'mp3 with large cover art' do + let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.mp3')) } it 'detects it as an audio file' do expect(media.type).to eq 'audio' @@ -141,75 +222,42 @@ RSpec.describe MediaAttachment, type: :model 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 'jpeg' do - let(:media) { MediaAttachment.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) { MediaAttachment.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 + expect(media.file_file_name).to_not eq 'boop.mp3' end end it 'is invalid without file' do - media = MediaAttachment.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 { MediaAttachment.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 = MediaAttachment.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 { MediaAttachment.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 = MediaAttachment.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/mention_spec.rb b/spec/models/mention_spec.rb index dbcf6a32c14..b241049a54b 100644 --- a/spec/models/mention_spec.rb +++ b/spec/models/mention_spec.rb @@ -1,12 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Mention, type: :model do +RSpec.describe Mention do describe 'validations' do - it 'has a valid fabricator' do - mention = Fabricate.build(:mention) - expect(mention).to be_valid - end - it 'is invalid without an account' do mention = Fabricate.build(:mention, account: nil) mention.valid? diff --git a/spec/models/mute_spec.rb b/spec/models/mute_spec.rb deleted file mode 100644 index 38a87bdf4a7..00000000000 --- a/spec/models/mute_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe Mute, type: :model do -end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb index a8fb7763909..d6e2282022f 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Notification, type: :model do +RSpec.describe Notification do describe '#target_status' do let(:notification) { Fabricate(:notification, activity: activity) } let(:status) { Fabricate(:status) } @@ -8,7 +10,7 @@ RSpec.describe Notification, type: :model do let(:favourite) { Fabricate(:favourite, status: status) } let(:mention) { Fabricate(:mention, status: status) } - context 'activity is reblog' do + context 'when Activity is reblog' do let(:activity) { reblog } it 'returns status' do @@ -16,7 +18,7 @@ RSpec.describe Notification, type: :model do end end - context 'activity is favourite' do + context 'when Activity is favourite' do let(:type) { :favourite } let(:activity) { favourite } @@ -25,7 +27,7 @@ RSpec.describe Notification, type: :model do end end - context 'activity is mention' do + context 'when Activity is mention' do let(:activity) { mention } it 'returns status' do @@ -36,22 +38,22 @@ RSpec.describe Notification, type: :model do describe '#type' do it 'returns :reblog for a Status' do - notification = Notification.new(activity: Status.new) + notification = described_class.new(activity: Status.new) expect(notification.type).to eq :reblog end it 'returns :mention for a Mention' do - notification = Notification.new(activity: Mention.new) + notification = described_class.new(activity: Mention.new) expect(notification.type).to eq :mention end it 'returns :favourite for a Favourite' do - notification = Notification.new(activity: Favourite.new) + notification = described_class.new(activity: Favourite.new) expect(notification.type).to eq :favourite end it 'returns :follow for a Follow' do - notification = Notification.new(activity: Follow.new) + notification = described_class.new(activity: Follow.new) expect(notification.type).to eq :follow end end @@ -64,7 +66,7 @@ RSpec.describe Notification, type: :model do end end - context 'notifications are empty' do + context 'when notifications are empty' do let(:notifications) { [] } it 'returns []' do @@ -72,7 +74,7 @@ RSpec.describe Notification, type: :model do end end - context 'notifications are present' do + context 'when notifications are present' do before do notifications.each(&:reload) end @@ -97,73 +99,87 @@ RSpec.describe Notification, type: :model do ] end - it 'preloads target status' do - # mention - expect(subject[0].type).to eq :mention - expect(subject[0].association(:mention)).to be_loaded - expect(subject[0].mention.association(:status)).to be_loaded + context 'with a preloaded target status' do + it 'preloads mention' do + expect(subject[0].type).to eq :mention + expect(subject[0].association(:mention)).to be_loaded + expect(subject[0].mention.association(:status)).to be_loaded + end - # status - expect(subject[1].type).to eq :status - expect(subject[1].association(:status)).to be_loaded + it 'preloads status' do + expect(subject[1].type).to eq :status + expect(subject[1].association(:status)).to be_loaded + end - # reblog - expect(subject[2].type).to eq :reblog - expect(subject[2].association(:status)).to be_loaded - expect(subject[2].status.association(:reblog)).to be_loaded + it 'preloads reblog' do + expect(subject[2].type).to eq :reblog + expect(subject[2].association(:status)).to be_loaded + expect(subject[2].status.association(:reblog)).to be_loaded + end - # follow: nothing - expect(subject[3].type).to eq :follow - expect(subject[3].target_status).to be_nil + it 'preloads follow as nil' do + expect(subject[3].type).to eq :follow + expect(subject[3].target_status).to be_nil + end - # follow_request: nothing - expect(subject[4].type).to eq :follow_request - expect(subject[4].target_status).to be_nil + it 'preloads follow_request as nill' do + expect(subject[4].type).to eq :follow_request + expect(subject[4].target_status).to be_nil + end - # favourite - expect(subject[5].type).to eq :favourite - expect(subject[5].association(:favourite)).to be_loaded - expect(subject[5].favourite.association(:status)).to be_loaded + it 'preloads favourite' do + expect(subject[5].type).to eq :favourite + expect(subject[5].association(:favourite)).to be_loaded + expect(subject[5].favourite.association(:status)).to be_loaded + end - # poll - expect(subject[6].type).to eq :poll - expect(subject[6].association(:poll)).to be_loaded - expect(subject[6].poll.association(:status)).to be_loaded + it 'preloads poll' do + expect(subject[6].type).to eq :poll + expect(subject[6].association(:poll)).to be_loaded + expect(subject[6].poll.association(:status)).to be_loaded + end end - it 'replaces to cached status' do - # mention - expect(subject[0].type).to eq :mention - expect(subject[0].target_status.association(:account)).to be_loaded - expect(subject[0].target_status).to eq mention.status + context 'with a cached status' do + it 'replaces mention' do + expect(subject[0].type).to eq :mention + expect(subject[0].target_status.association(:account)).to be_loaded + expect(subject[0].target_status).to eq mention.status + end - # status - expect(subject[1].type).to eq :status - expect(subject[1].target_status.association(:account)).to be_loaded - expect(subject[1].target_status).to eq status + it 'replaces status' do + expect(subject[1].type).to eq :status + expect(subject[1].target_status.association(:account)).to be_loaded + expect(subject[1].target_status).to eq status + end - # reblog - expect(subject[2].type).to eq :reblog - expect(subject[2].target_status.association(:account)).to be_loaded - expect(subject[2].target_status).to eq reblog.reblog + it 'replaces reblog' do + expect(subject[2].type).to eq :reblog + expect(subject[2].target_status.association(:account)).to be_loaded + expect(subject[2].target_status).to eq reblog.reblog + end - # follow: nothing - expect(subject[3].type).to eq :follow - expect(subject[3].target_status).to be_nil + it 'replaces follow' do + expect(subject[3].type).to eq :follow + expect(subject[3].target_status).to be_nil + end - # follow_request: nothing - expect(subject[4].type).to eq :follow_request - expect(subject[4].target_status).to be_nil + it 'replaces follow_request' do + expect(subject[4].type).to eq :follow_request + expect(subject[4].target_status).to be_nil + end - # favourite - expect(subject[5].type).to eq :favourite - expect(subject[5].target_status.association(:account)).to be_loaded - expect(subject[5].target_status).to eq favourite.status + it 'replaces favourite' do + expect(subject[5].type).to eq :favourite + expect(subject[5].target_status.association(:account)).to be_loaded + expect(subject[5].target_status).to eq favourite.status + end - # poll - expect(subject[6].type).to eq :poll - expect(subject[6].target_status.association(:account)).to be_loaded - expect(subject[6].target_status).to eq poll.status + it 'replaces poll' do + expect(subject[6].type).to eq :poll + expect(subject[6].target_status.association(:account)).to be_loaded + expect(subject[6].target_status).to eq poll.status + end end end end diff --git a/spec/models/one_time_key_spec.rb b/spec/models/one_time_key_spec.rb index 4b231c600c9..6ff7ffc5c14 100644 --- a/spec/models/one_time_key_spec.rb +++ b/spec/models/one_time_key_spec.rb @@ -1,4 +1,23 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe OneTimeKey, type: :model do +describe OneTimeKey do + describe 'validations' do + context 'with an invalid signature' do + let(:one_time_key) { Fabricate.build(:one_time_key, signature: 'wrong!') } + + it 'is invalid' do + expect(one_time_key).to_not be_valid + end + end + + context 'with an invalid key' do + let(:one_time_key) { Fabricate.build(:one_time_key, key: 'wrong!') } + + it 'is invalid' do + expect(one_time_key).to_not be_valid + end + end + end end diff --git a/spec/models/poll_spec.rb b/spec/models/poll_spec.rb index 666f8ca6832..5aa5548cc83 100644 --- a/spec/models/poll_spec.rb +++ b/spec/models/poll_spec.rb @@ -1,5 +1,51 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Poll, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +describe Poll do + describe 'scopes' do + let(:status) { Fabricate(:status) } + let(:attached_poll) { Fabricate(:poll, status: status) } + let(:not_attached_poll) do + Fabricate(:poll).tap do |poll| + poll.status = nil + poll.save(validate: false) + end + end + + describe 'attached' do + it 'finds the correct records' do + results = described_class.attached + + expect(results).to eq([attached_poll]) + end + end + + describe 'unattached' do + it 'finds the correct records' do + results = described_class.unattached + + expect(results).to eq([not_attached_poll]) + 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/poll_vote_spec.rb b/spec/models/poll_vote_spec.rb index 563f346993a..b017ea52799 100644 --- a/spec/models/poll_vote_spec.rb +++ b/spec/models/poll_vote_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe PollVote, type: :model do +RSpec.describe PollVote do describe '#object_type' do let(:poll_vote) { Fabricate.build(:poll_vote) } @@ -10,4 +10,53 @@ RSpec.describe PollVote, type: :model do expect(poll_vote.object_type).to eq :vote end end + + describe 'validations' do + context 'with a vote on an expired poll' do + it 'marks the vote invalid' do + poll = Fabricate.build(:poll, expires_at: 30.days.ago) + + vote = Fabricate.build(:poll_vote, poll: poll) + expect(vote).to_not be_valid + end + end + + context 'with invalid choices' do + it 'marks vote invalid with negative choice' do + poll = Fabricate.build(:poll) + + vote = Fabricate.build(:poll_vote, poll: poll, choice: -100) + expect(vote).to_not be_valid + end + + it 'marks vote invalid with choice in excess of options' do + poll = Fabricate.build(:poll, options: %w(a b c)) + + vote = Fabricate.build(:poll_vote, poll: poll, choice: 10) + expect(vote).to_not be_valid + end + end + + context 'with a poll where multiple is true' do + it 'does not allow a second vote on same choice from same account' do + poll = Fabricate(:poll, multiple: true, options: %w(a b c)) + first_vote = Fabricate(:poll_vote, poll: poll, choice: 1) + expect(first_vote).to be_valid + + second_vote = Fabricate.build(:poll_vote, account: first_vote.account, poll: poll, choice: 1) + expect(second_vote).to_not be_valid + end + end + + context 'with a poll where multiple is false' do + it 'does not allow a second vote from same account' do + poll = Fabricate(:poll, multiple: false, options: %w(a b c)) + first_vote = Fabricate(:poll_vote, poll: poll) + expect(first_vote).to be_valid + + second_vote = Fabricate.build(:poll_vote, account: first_vote.account, poll: poll) + expect(second_vote).to_not be_valid + end + end + end end diff --git a/spec/models/preview_card_provider_spec.rb b/spec/models/preview_card_provider_spec.rb new file mode 100644 index 00000000000..7425b939462 --- /dev/null +++ b/spec/models/preview_card_provider_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe PreviewCardProvider do + describe 'scopes' do + let(:trendable_and_reviewed) { Fabricate(:preview_card_provider, trendable: true, reviewed_at: 5.days.ago) } + let(:not_trendable_and_not_reviewed) { Fabricate(:preview_card_provider, trendable: false, reviewed_at: nil) } + + describe 'trendable' do + it 'returns the relevant records' do + results = described_class.trendable + + expect(results).to eq([trendable_and_reviewed]) + end + end + + describe 'not_trendable' do + it 'returns the relevant records' do + results = described_class.not_trendable + + expect(results).to eq([not_trendable_and_not_reviewed]) + end + end + + describe 'reviewed' do + it 'returns the relevant records' do + results = described_class.reviewed + + expect(results).to eq([trendable_and_reviewed]) + end + end + + describe 'pending_review' do + it 'returns the relevant records' do + results = described_class.pending_review + + expect(results).to eq([not_trendable_and_not_reviewed]) + end + end + end +end diff --git a/spec/models/preview_card_spec.rb b/spec/models/preview_card_spec.rb index 45233d1d4f9..a17c7532e9e 100644 --- a/spec/models/preview_card_spec.rb +++ b/spec/models/preview_card_spec.rb @@ -1,4 +1,28 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe PreviewCard, type: :model 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 c7ab6ed146e..00000000000 --- a/spec/models/preview_card_trend_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe PreviewCardTrend, type: :model do -end diff --git a/spec/models/privacy_policy_spec.rb b/spec/models/privacy_policy_spec.rb new file mode 100644 index 00000000000..0d747137550 --- /dev/null +++ b/spec/models/privacy_policy_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe PrivacyPolicy do + describe '.current' do + context 'with the default values' do + it 'has the privacy text' do + policy = described_class.current + + expect(policy.text).to eq(PrivacyPolicy::DEFAULT_PRIVACY_POLICY) + end + end + + context 'with a custom setting value' do + before do + terms_setting = instance_double(Setting, value: 'Terms text', updated_at: 10.days.ago) + allow(Setting).to receive(:find_by).with(var: 'site_terms').and_return(terms_setting) + end + + it 'has the privacy text' do + policy = described_class.current + + expect(policy.text).to eq('Terms text') + end + end + end +end diff --git a/spec/models/public_feed_spec.rb b/spec/models/public_feed_spec.rb index 59c81dd9530..53e01cafd39 100644 --- a/spec/models/public_feed_spec.rb +++ b/spec/models/public_feed_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe PublicFeed, type: :model do +RSpec.describe PublicFeed do let(:account) { Fabricate(:account) } describe '#get' do diff --git a/spec/models/relationship_filter_spec.rb b/spec/models/relationship_filter_spec.rb index 7c0f37a06f2..fccd42aaad0 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 - RelationshipFilter.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 12dc0f20f6e..00000000000 --- a/spec/models/relay_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe Relay, type: :model do -end diff --git a/spec/models/remote_follow_spec.rb b/spec/models/remote_follow_spec.rb index ea36b00769e..81c726a40ba 100644 --- a/spec/models/remote_follow_spec.rb +++ b/spec/models/remote_follow_spec.rb @@ -13,7 +13,7 @@ RSpec.describe RemoteFollow do describe '.initialize' do subject { remote_follow.acct } - context 'attrs with acct' do + context 'when attrs with acct' do let(:attrs) { { acct: 'gargron@quitter.no' } } it 'returns acct' do @@ -21,7 +21,7 @@ RSpec.describe RemoteFollow do end end - context 'attrs without acct' do + context 'when attrs without acct' do let(:attrs) { {} } it do @@ -33,7 +33,7 @@ RSpec.describe RemoteFollow do describe '#valid?' do subject { remote_follow.valid? } - context 'attrs with acct' do + context 'when attrs with acct' do let(:attrs) { { acct: 'gargron@quitter.no' } } it do @@ -41,7 +41,7 @@ RSpec.describe RemoteFollow do end end - context 'attrs without acct' do + context 'when attrs without acct' do let(:attrs) { {} } it do diff --git a/spec/models/report_filter_spec.rb b/spec/models/report_filter_spec.rb index 099c0731d34..6baf0ea421c 100644 --- a/spec/models/report_filter_spec.rb +++ b/spec/models/report_filter_spec.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + require 'rails_helper' describe ReportFilter do describe 'with empty params' do it 'defaults to unresolved reports list' do - filter = ReportFilter.new({}) + filter = described_class.new({}) expect(filter.results).to eq Report.unresolved end @@ -11,7 +13,7 @@ describe ReportFilter do describe 'with invalid params' do it 'raises with key error' do - filter = ReportFilter.new(wrong: true) + filter = described_class.new(wrong: true) expect { filter.results }.to raise_error(/wrong/) end @@ -19,10 +21,9 @@ describe ReportFilter do describe 'with valid params' do it 'combines filters on Report' do - filter = ReportFilter.new(account_id: '123', resolved: true, target_account_id: '456') + 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 31785129741..0093dcd8de9 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe Report do @@ -87,13 +89,13 @@ describe Report do let(:report) { Fabricate(:report, action_taken_at: action_taken) } - context 'if action is taken' do + context 'when action is taken' do let(:action_taken) { Time.now.utc } it { is_expected.to be false } end - context 'if action not is taken' do + context 'when action not is taken' do let(:action_taken) { nil } it { is_expected.to be true } @@ -119,16 +121,17 @@ describe Report do end describe 'validations' do - it 'has a valid fabricator' do - report = Fabricate(:report) - report.valid? - expect(report).to be_valid + let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } + + it 'is invalid if comment is longer than 1000 characters only if reporter is local' do + report = Fabricate.build(:report, comment: Faker::Lorem.characters(number: 1001)) + expect(report.valid?).to be false + expect(report).to model_have_error_on_field(:comment) end - it 'is invalid if comment is longer than 1000 characters' do - report = Fabricate.build(:report, comment: Faker::Lorem.characters(number: 1001)) - report.valid? - expect(report).to model_have_error_on_field(:comment) + it 'is valid if comment is longer than 1000 characters and reporter is not local' do + report = Fabricate.build(:report, account: remote_account, comment: Faker::Lorem.characters(number: 1001)) + expect(report.valid?).to be true end end end diff --git a/spec/models/rule_spec.rb b/spec/models/rule_spec.rb index 8666bda7130..c9b9c55028f 100644 --- a/spec/models/rule_spec.rb +++ b/spec/models/rule_spec.rb @@ -1,5 +1,19 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Rule, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +describe Rule do + describe 'scopes' do + describe 'ordered' do + let(:deleted_rule) { Fabricate(:rule, deleted_at: 10.days.ago) } + let(:first_rule) { Fabricate(:rule, deleted_at: nil, priority: 1) } + let(:last_rule) { Fabricate(:rule, deleted_at: nil, priority: 10) } + + it 'finds the correct records' do + results = described_class.ordered + + expect(results).to eq([first_rule, last_rule]) + end + end + end end diff --git a/spec/models/scheduled_status_spec.rb b/spec/models/scheduled_status_spec.rb deleted file mode 100644 index f8c9d8b81f6..00000000000 --- a/spec/models/scheduled_status_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe ScheduledStatus, type: :model do -end diff --git a/spec/models/session_activation_spec.rb b/spec/models/session_activation_spec.rb index 375199d5755..75842e25bad 100644 --- a/spec/models/session_activation_spec.rb +++ b/spec/models/session_activation_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe SessionActivation, type: :model do +RSpec.describe SessionActivation do describe '#detection' do let(:session_activation) { Fabricate(:session_activation, user_agent: 'Chrome/62.0.3202.89') } @@ -16,7 +16,7 @@ RSpec.describe SessionActivation, type: :model do allow(session_activation).to receive(:detection).and_return(detection) end - let(:detection) { double(id: 1) } + let(:detection) { instance_double(Browser::Chrome, id: 1) } let(:session_activation) { Fabricate(:session_activation) } it 'returns detection.id' do @@ -30,7 +30,7 @@ RSpec.describe SessionActivation, type: :model do end let(:session_activation) { Fabricate(:session_activation) } - let(:detection) { double(platform: double(id: 1)) } + let(:detection) { instance_double(Browser::Chrome, platform: instance_double(Browser::Platform, id: 1)) } it 'returns detection.platform.id' do expect(session_activation.platform).to be 1 @@ -40,7 +40,7 @@ RSpec.describe SessionActivation, type: :model do describe '.active?' do subject { described_class.active?(id) } - context 'id is absent' do + context 'when id is absent' do let(:id) { nil } it 'returns nil' do @@ -48,17 +48,17 @@ RSpec.describe SessionActivation, type: :model do end end - context 'id is present' do + context 'when id is present' do let(:id) { '1' } let!(:session_activation) { Fabricate(:session_activation, session_id: id) } - context 'id exists as session_id' do + context 'when id exists as session_id' do it 'returns true' do expect(subject).to be true end end - context 'id does not exist as session_id' do + context 'when id does not exist as session_id' do before do session_activation.update!(session_id: '2') end @@ -80,12 +80,12 @@ RSpec.describe SessionActivation, type: :model do end it 'returns an instance of SessionActivation' do - expect(described_class.activate(**options)).to be_a SessionActivation + expect(described_class.activate(**options)).to be_a described_class end end describe '.deactivate' do - context 'id is absent' do + context 'when id is absent' do let(:id) { nil } it 'returns nil' do @@ -93,7 +93,7 @@ RSpec.describe SessionActivation, type: :model do end end - context 'id exists' do + context 'when id exists' do let(:id) { '1' } it 'calls where.destroy_all' do diff --git a/spec/models/setting_spec.rb b/spec/models/setting_spec.rb index 826a13878f6..5ed5c5d766c 100644 --- a/spec/models/setting_spec.rb +++ b/spec/models/setting_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe Setting, type: :model do +RSpec.describe Setting do describe '#to_param' do let(:setting) { Fabricate(:setting, var: var) } let(:var) { 'var' } @@ -19,7 +19,7 @@ RSpec.describe Setting, type: :model do let(:key) { 'key' } - context 'rails_initialized? is falsey' do + context 'when rails_initialized? is falsey' do let(:rails_initialized) { false } it 'calls RailsSettings::Base#[]' do @@ -28,7 +28,7 @@ RSpec.describe Setting, type: :model do end end - context 'rails_initialized? is truthy' do + context 'when rails_initialized? is truthy' do before do allow(RailsSettings::Base).to receive(:cache_key).with(key, nil).and_return(cache_key) end @@ -42,7 +42,7 @@ RSpec.describe Setting, type: :model do described_class[key] end - context 'Rails.cache does not exists' do + 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) @@ -60,11 +60,11 @@ RSpec.describe Setting, type: :model do described_class[key] end - context 'RailsSettings::Settings.object returns truthy' do + context 'when RailsSettings::Settings.object returns truthy' do let(:object) { db_val } - let(:db_val) { double(value: 'db_val') } + let(:db_val) { instance_double(described_class, value: 'db_val') } - context 'default_value is a Hash' do + context 'when default_value is a Hash' do let(:default_value) { { default_value: 'default_value' } } it 'calls default_value.with_indifferent_access.merge!' do @@ -75,7 +75,7 @@ RSpec.describe Setting, type: :model do end end - context 'default_value is not a Hash' do + context 'when default_value is not a Hash' do let(:default_value) { 'default_value' } it 'returns db_val.value' do @@ -84,7 +84,7 @@ RSpec.describe Setting, type: :model do end end - context 'RailsSettings::Settings.object returns falsey' do + context 'when RailsSettings::Settings.object returns falsey' do let(:object) { nil } it 'returns default_settings[key]' do @@ -93,7 +93,7 @@ RSpec.describe Setting, type: :model do end end - context 'Rails.cache exists' do + context 'when Rails.cache exists' do before do Rails.cache.write(cache_key, cache_value) end @@ -130,7 +130,7 @@ RSpec.describe Setting, type: :model do expect(described_class.all_as_records).to be_a Hash end - context 'records includes Setting with var as the key' do + context 'when records includes Setting with var as the key' do let(:records) { [original_setting] } it 'includes the original Setting' do @@ -139,20 +139,20 @@ RSpec.describe Setting, type: :model do end end - context 'records includes nothing' do + context 'when records includes nothing' do let(:records) { [] } - context 'default_value is not a Hash' do + 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 Setting + expect(setting).to be_a described_class expect(setting).to have_attributes(var: key) expect(setting).to have_attributes(value: 'default_value') end end - context 'default_value is a Hash' do + context 'when default_value is a Hash' do let(:default_value) { { 'foo' => 'fuga' } } it 'returns {}' do @@ -169,7 +169,7 @@ RSpec.describe Setting, type: :model do allow(RailsSettings::Default).to receive(:enabled?).and_return(enabled) end - context 'RailsSettings::Default.enabled? is false' do + context 'when RailsSettings::Default.enabled? is false' do let(:enabled) { false } it 'returns {}' do @@ -177,7 +177,7 @@ RSpec.describe Setting, type: :model do end end - context 'RailsSettings::Settings.enabled? is true' do + context 'when RailsSettings::Settings.enabled? is true' do let(:enabled) { true } it 'returns instance of RailsSettings::Default' do diff --git a/spec/models/site_upload_spec.rb b/spec/models/site_upload_spec.rb index f7ea0692130..9689bce9ee1 100644 --- a/spec/models/site_upload_spec.rb +++ b/spec/models/site_upload_spec.rb @@ -2,9 +2,9 @@ require 'rails_helper' -RSpec.describe SiteUpload, type: :model do +RSpec.describe SiteUpload do describe '#cache_key' do - let(:site_upload) { SiteUpload.new(var: 'var') } + let(:site_upload) { described_class.new(var: 'var') } it 'returns cache_key' do expect(site_upload.cache_key).to eq 'site_uploads/var' diff --git a/spec/models/software_update_spec.rb b/spec/models/software_update_spec.rb new file mode 100644 index 00000000000..0a494b0c4ce --- /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_edit_spec.rb b/spec/models/status_edit_spec.rb index 2ecafef7349..2d335145225 100644 --- a/spec/models/status_edit_spec.rb +++ b/spec/models/status_edit_spec.rb @@ -1,5 +1,13 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe StatusEdit, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +describe StatusEdit do + describe '#reblog?' do + it 'returns false' do + record = described_class.new + + expect(record).to_not be_a_reblog + end + end end diff --git a/spec/models/status_pin_spec.rb b/spec/models/status_pin_spec.rb index c18faca782e..660b2e92ac8 100644 --- a/spec/models/status_pin_spec.rb +++ b/spec/models/status_pin_spec.rb @@ -1,19 +1,21 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe StatusPin, type: :model do +RSpec.describe StatusPin do describe 'validations' do it 'allows pins of own statuses' do account = Fabricate(:account) status = Fabricate(:status, account: account) - expect(StatusPin.new(account: account, status: status).save).to be true + expect(described_class.new(account: account, status: status).save).to be true end it 'does not allow pins of statuses by someone else' do account = Fabricate(:account) status = Fabricate(:status) - expect(StatusPin.new(account: account, status: status).save).to be false + expect(described_class.new(account: account, status: status).save).to be false end it 'does not allow pins of reblogs' do @@ -21,21 +23,21 @@ RSpec.describe StatusPin, type: :model do status = Fabricate(:status, account: account) reblog = Fabricate(:status, reblog: status) - expect(StatusPin.new(account: account, status: reblog).save).to be false + expect(described_class.new(account: account, status: reblog).save).to be false end it 'does allow pins of direct statuses' do account = Fabricate(:account) status = Fabricate(:status, account: account, visibility: :private) - expect(StatusPin.new(account: account, status: status).save).to be true + expect(described_class.new(account: account, status: status).save).to be true end it 'does not allow pins of direct statuses' do account = Fabricate(:account) status = Fabricate(:status, account: account, visibility: :direct) - expect(StatusPin.new(account: account, status: status).save).to be false + expect(described_class.new(account: account, status: status).save).to be false end max_pins = 5 @@ -48,10 +50,10 @@ RSpec.describe StatusPin, type: :model do end max_pins.times do |i| - expect(StatusPin.new(account: account, status: status[i]).save).to be true + expect(described_class.new(account: account, status: status[i]).save).to be true end - expect(StatusPin.new(account: account, status: status[max_pins]).save).to be false + 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 @@ -63,10 +65,10 @@ RSpec.describe StatusPin, type: :model do end max_pins.times do |i| - expect(StatusPin.new(account: account, status: status[i]).save).to be true + expect(described_class.new(account: account, status: status[i]).save).to be true end - expect(StatusPin.new(account: account, status: status[max_pins]).save).to be true + 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 442f14ddfa0..938d0546df0 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Status, type: :model do +RSpec.describe Status do subject { Fabricate(:status, account: alice) } let(:alice) { Fabricate(:account, username: 'alice') } @@ -47,22 +49,22 @@ RSpec.describe Status, type: :model do end describe '#verb' do - context 'if destroyed?' do + context 'when destroyed?' do it 'returns :delete' do subject.destroy! expect(subject.verb).to be :delete end end - context 'unless destroyed?' do - context 'if reblog?' do + context 'when not destroyed?' do + context 'when reblog?' do it 'returns :share' do subject.reblog = other expect(subject.verb).to be :share end end - context 'unless reblog?' do + context 'when not reblog?' do it 'returns :post' do subject.reblog = nil expect(subject.verb).to be :post @@ -83,28 +85,28 @@ RSpec.describe Status, type: :model do end describe '#hidden?' do - context 'if private_visibility?' do + context 'when private_visibility?' do it 'returns true' do subject.visibility = :private expect(subject.hidden?).to be true end end - context 'if direct_visibility?' do + context 'when direct_visibility?' do it 'returns true' do subject.visibility = :direct expect(subject.hidden?).to be true end end - context 'if public_visibility?' do + context 'when public_visibility?' do it 'returns false' do subject.visibility = :public expect(subject.hidden?).to be false end end - context 'if unlisted_visibility?' do + context 'when unlisted_visibility?' do it 'returns false' do subject.visibility = :unlisted expect(subject.hidden?).to be false @@ -158,13 +160,13 @@ RSpec.describe Status, type: :model do reblog = Fabricate(:status, account: bob, reblog: subject) expect(subject.reblogs_count).to eq 1 expect { subject.destroy }.to_not raise_error - expect(Status.find_by(id: reblog.id)).to be_nil + expect(described_class.find_by(id: reblog.id)).to be_nil end end 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 @@ -204,7 +206,7 @@ RSpec.describe Status, type: :model do end describe '.mutes_map' do - subject { Status.mutes_map([status.conversation.id], account) } + subject { described_class.mutes_map([status.conversation.id], account) } let(:status) { Fabricate(:status) } let(:account) { Fabricate(:account) } @@ -220,7 +222,7 @@ RSpec.describe Status, type: :model do end describe '.favourites_map' do - subject { Status.favourites_map([status], account) } + subject { described_class.favourites_map([status], account) } let(:status) { Fabricate(:status) } let(:account) { Fabricate(:account) } @@ -236,7 +238,7 @@ RSpec.describe Status, type: :model do end describe '.reblogs_map' do - subject { Status.reblogs_map([status], account) } + subject { described_class.reblogs_map([status], account) } let(:status) { Fabricate(:status) } let(:account) { Fabricate(:account) } @@ -252,82 +254,82 @@ RSpec.describe Status, type: :model do end describe '.tagged_with' do - let(:tag1) { Fabricate(:tag) } - let(:tag2) { Fabricate(:tag) } - let(:tag3) { Fabricate(:tag) } - let!(:status1) { Fabricate(:status, tags: [tag1]) } - let!(:status2) { Fabricate(:status, tags: [tag2]) } - let!(:status3) { Fabricate(:status, tags: [tag3]) } - let!(:status4) { Fabricate(:status, tags: []) } - let!(:status5) { Fabricate(:status, tags: [tag1, tag2, tag3]) } + let(:tag_cats) { Fabricate(:tag, name: 'cats') } + let(:tag_dogs) { Fabricate(:tag, name: 'dogs') } + let(:tag_zebras) { Fabricate(:tag, name: 'zebras') } + let!(:status_with_tag_cats) { Fabricate(:status, tags: [tag_cats]) } + let!(:status_with_tag_dogs) { Fabricate(:status, tags: [tag_dogs]) } + let!(:status_tagged_with_zebras) { Fabricate(:status, tags: [tag_zebras]) } + let!(:status_without_tags) { Fabricate(:status, tags: []) } + let!(:status_with_all_tags) { Fabricate(:status, tags: [tag_cats, tag_dogs, tag_zebras]) } context 'when given one tag' do it 'returns the expected statuses' do - expect(Status.tagged_with([tag1.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status5.id]) - expect(Status.tagged_with([tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status2.id, status5.id]) - expect(Status.tagged_with([tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status3.id, status5.id]) + 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) end end context 'when given multiple tags' do it 'returns the expected statuses' do - expect(Status.tagged_with([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status2.id, status5.id]) - expect(Status.tagged_with([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status3.id, status5.id]) - expect(Status.tagged_with([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status2.id, status3.id, status5.id]) + 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) end end end describe '.tagged_with_all' do - let(:tag1) { Fabricate(:tag) } - let(:tag2) { Fabricate(:tag) } - let(:tag3) { Fabricate(:tag) } - let!(:status1) { Fabricate(:status, tags: [tag1]) } - let!(:status2) { Fabricate(:status, tags: [tag2]) } - let!(:status3) { Fabricate(:status, tags: [tag3]) } - let!(:status4) { Fabricate(:status, tags: []) } - let!(:status5) { Fabricate(:status, tags: [tag1, tag2]) } + let(:tag_cats) { Fabricate(:tag, name: 'cats') } + let(:tag_dogs) { Fabricate(:tag, name: 'dogs') } + let(:tag_zebras) { Fabricate(:tag, name: 'zebras') } + let!(:status_with_tag_cats) { Fabricate(:status, tags: [tag_cats]) } + let!(:status_with_tag_dogs) { Fabricate(:status, tags: [tag_dogs]) } + let!(:status_tagged_with_zebras) { Fabricate(:status, tags: [tag_zebras]) } + let!(:status_without_tags) { Fabricate(:status, tags: []) } + let!(:status_with_all_tags) { Fabricate(:status, tags: [tag_cats, tag_dogs]) } context 'when given one tag' do it 'returns the expected statuses' do - expect(Status.tagged_with_all([tag1.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status5.id]) - expect(Status.tagged_with_all([tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status2.id, status5.id]) - expect(Status.tagged_with_all([tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status3.id]) + 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) end end context 'when given multiple tags' do it 'returns the expected statuses' do - expect(Status.tagged_with_all([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status5.id]) - expect(Status.tagged_with_all([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [] - expect(Status.tagged_with_all([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [] + 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 [] end end end describe '.tagged_with_none' do - let(:tag1) { Fabricate(:tag) } - let(:tag2) { Fabricate(:tag) } - let(:tag3) { Fabricate(:tag) } - let!(:status1) { Fabricate(:status, tags: [tag1]) } - let!(:status2) { Fabricate(:status, tags: [tag2]) } - let!(:status3) { Fabricate(:status, tags: [tag3]) } - let!(:status4) { Fabricate(:status, tags: []) } - let!(:status5) { Fabricate(:status, tags: [tag1, tag2, tag3]) } + let(:tag_cats) { Fabricate(:tag, name: 'cats') } + let(:tag_dogs) { Fabricate(:tag, name: 'dogs') } + let(:tag_zebras) { Fabricate(:tag, name: 'zebras') } + let!(:status_with_tag_cats) { Fabricate(:status, tags: [tag_cats]) } + let!(:status_with_tag_dogs) { Fabricate(:status, tags: [tag_dogs]) } + let!(:status_tagged_with_zebras) { Fabricate(:status, tags: [tag_zebras]) } + let!(:status_without_tags) { Fabricate(:status, tags: []) } + let!(:status_with_all_tags) { Fabricate(:status, tags: [tag_cats, tag_dogs, tag_zebras]) } context 'when given one tag' do it 'returns the expected statuses' do - expect(Status.tagged_with_none([tag1.id]).reorder(:id).pluck(:id).uniq).to match_array([status2.id, status3.id, status4.id]) - expect(Status.tagged_with_none([tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status3.id, status4.id]) - expect(Status.tagged_with_none([tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status2.id, status4.id]) + 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) end end context 'when given multiple tags' do it 'returns the expected statuses' do - expect(Status.tagged_with_none([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status3.id, status4.id]) - expect(Status.tagged_with_none([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status2.id, status4.id]) - expect(Status.tagged_with_none([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status4.id]) + 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) end end end @@ -342,21 +344,21 @@ RSpec.describe Status, type: :model do end it 'creates new conversation for stand-alone status' do - expect(Status.create(account: alice, text: 'First').conversation_id).to_not be_nil + expect(described_class.create(account: alice, text: 'First').conversation_id).to_not be_nil end it 'keeps conversation of parent node' do parent = Fabricate(:status, text: 'First') - expect(Status.create(account: alice, thread: parent, text: 'Response').conversation_id).to eq parent.conversation_id + expect(described_class.create(account: alice, thread: parent, text: 'Response').conversation_id).to eq parent.conversation_id end it 'sets `local` to true for status by local account' do - expect(Status.create(account: alice, text: 'foo').local).to be true + expect(described_class.create(account: alice, text: 'foo').local).to be true end it 'sets `local` to false for status by remote account' do alice.update(domain: 'example.com') - expect(Status.create(account: alice, text: 'foo').local).to be false + expect(described_class.create(account: alice, text: 'foo').local).to be false end end @@ -370,7 +372,7 @@ RSpec.describe Status, type: :model do describe 'after_create' do it 'saves ActivityPub uri as uri for local status' do - status = Status.create(account: alice, text: 'foo') + status = described_class.create(account: alice, text: 'foo') status.reload expect(status.uri).to start_with('https://') end diff --git a/spec/models/status_stat_spec.rb b/spec/models/status_stat_spec.rb deleted file mode 100644 index af1a6f288be..00000000000 --- a/spec/models/status_stat_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe StatusStat, type: :model do -end diff --git a/spec/models/status_trend_spec.rb b/spec/models/status_trend_spec.rb deleted file mode 100644 index 6b82204a609..00000000000 --- a/spec/models/status_trend_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe StatusTrend, type: :model do -end diff --git a/spec/models/system_key_spec.rb b/spec/models/system_key_spec.rb deleted file mode 100644 index 86f07f964d4..00000000000 --- a/spec/models/system_key_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe SystemKey, type: :model do -end diff --git a/spec/models/tag_feed_spec.rb b/spec/models/tag_feed_spec.rb index 819fe376577..6f5e1eb307e 100644 --- a/spec/models/tag_feed_spec.rb +++ b/spec/models/tag_feed_spec.rb @@ -1,67 +1,69 @@ +# frozen_string_literal: true + require 'rails_helper' describe TagFeed, type: :service do describe '#get' do let(:account) { Fabricate(:account) } - let(:tag1) { Fabricate(:tag) } - let(:tag2) { Fabricate(:tag) } - let!(:status1) { Fabricate(:status, tags: [tag1]) } - let!(:status2) { Fabricate(:status, tags: [tag2]) } - let!(:both) { Fabricate(:status, tags: [tag1, tag2]) } + let(:tag_cats) { Fabricate(:tag, name: 'cats') } + let(:tag_dogs) { Fabricate(:tag, name: 'dogs') } + let!(:status_tagged_with_cats) { Fabricate(:status, tags: [tag_cats]) } + let!(:status_tagged_with_dogs) { Fabricate(:status, tags: [tag_dogs]) } + let!(:both) { Fabricate(:status, tags: [tag_cats, tag_dogs]) } it 'can add tags in "any" mode' do - results = described_class.new(tag1, nil, any: [tag2.name]).get(20) - expect(results).to include status1 - expect(results).to include status2 + results = described_class.new(tag_cats, nil, any: [tag_dogs.name]).get(20) + expect(results).to include status_tagged_with_cats + expect(results).to include status_tagged_with_dogs expect(results).to include both end it 'can remove tags in "all" mode' do - results = described_class.new(tag1, nil, all: [tag2.name]).get(20) - expect(results).to_not include status1 - expect(results).to_not include status2 + results = described_class.new(tag_cats, nil, all: [tag_dogs.name]).get(20) + expect(results).to_not include status_tagged_with_cats + expect(results).to_not include status_tagged_with_dogs expect(results).to include both end it 'can remove tags in "none" mode' do - results = described_class.new(tag1, nil, none: [tag2.name]).get(20) - expect(results).to include status1 - expect(results).to_not include status2 + results = described_class.new(tag_cats, nil, none: [tag_dogs.name]).get(20) + expect(results).to include status_tagged_with_cats + expect(results).to_not include status_tagged_with_dogs expect(results).to_not include both end it 'ignores an invalid mode' do - results = described_class.new(tag1, nil, wark: [tag2.name]).get(20) - expect(results).to include status1 - expect(results).to_not include status2 + results = described_class.new(tag_cats, nil, wark: [tag_dogs.name]).get(20) + expect(results).to include status_tagged_with_cats + expect(results).to_not include status_tagged_with_dogs expect(results).to include both end it 'handles being passed non existent tag names' do - results = described_class.new(tag1, nil, any: ['wark']).get(20) - expect(results).to include status1 - expect(results).to_not include status2 + results = described_class.new(tag_cats, nil, any: ['wark']).get(20) + expect(results).to include status_tagged_with_cats + expect(results).to_not include status_tagged_with_dogs expect(results).to include both end it 'can restrict to an account' do - BlockService.new.call(account, status1.account) - results = described_class.new(tag1, account, none: [tag2.name]).get(20) - expect(results).to_not include status1 + BlockService.new.call(account, status_tagged_with_cats.account) + results = described_class.new(tag_cats, account, none: [tag_dogs.name]).get(20) + expect(results).to_not include status_tagged_with_cats end it 'can restrict to local' do - status1.account.update(domain: 'example.com') - status1.update(local: false, uri: 'example.com/toot') - results = described_class.new(tag1, nil, any: [tag2.name], local: true).get(20) - expect(results).to_not include status1 + status_tagged_with_cats.account.update(domain: 'example.com') + status_tagged_with_cats.update(local: false, uri: 'example.com/toot') + results = described_class.new(tag_cats, nil, any: [tag_dogs.name], local: true).get(20) + expect(results).to_not include status_tagged_with_cats end it 'allows replies to be included' do original = Fabricate(:status) - status = Fabricate(:status, tags: [tag1], in_reply_to_id: original.id) + status = Fabricate(:status, tags: [tag_cats], in_reply_to_id: original.id) - results = described_class.new(tag1, nil).get(20) + results = described_class.new(tag_cats, nil).get(20) expect(results).to include(status) end end diff --git a/spec/models/tag_follow_spec.rb b/spec/models/tag_follow_spec.rb deleted file mode 100644 index 50c04d2e460..00000000000 --- a/spec/models/tag_follow_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe TagFollow, type: :model do -end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 4d6e5c380b0..80b9c861fcd 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -32,44 +32,48 @@ 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 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 +81,7 @@ RSpec.describe Tag do end it 'does not match middle dots at the end' do - expect(subject.match('hello #one·two·three·').to_s).to eq ' #one·two·three' + expect(subject.match('hello #one·two·three·').to_s).to eq '#one·two·three' end it 'does not match purely-numeric hashtags' do diff --git a/spec/models/trends/statuses_spec.rb b/spec/models/trends/statuses_spec.rb index 98a8c7264da..7c30b5b9976 100644 --- a/spec/models/trends/statuses_spec.rb +++ b/spec/models/trends/statuses_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Trends::Statuses do @@ -9,12 +11,12 @@ RSpec.describe Trends::Statuses do let!(:query) { subject.query } let!(:today) { at_time } - let!(:status1) { Fabricate(:status, text: 'Foo', language: 'en', trendable: true, created_at: today) } - let!(:status2) { Fabricate(:status, text: 'Bar', language: 'en', trendable: true, created_at: today) } + let!(:status_foo) { Fabricate(:status, text: 'Foo', language: 'en', trendable: true, created_at: today) } + let!(:status_bar) { Fabricate(:status, text: 'Bar', language: 'en', trendable: true, created_at: today) } before do - 15.times { reblog(status1, today) } - 12.times { reblog(status2, today) } + default_threshold_value.times { reblog(status_foo, today) } + default_threshold_value.times { reblog(status_bar, today) } subject.refresh(today) end @@ -27,18 +29,18 @@ RSpec.describe Trends::Statuses do end it 'filters out blocked accounts' do - account.block!(status1.account) - expect(query.filtered_for(account).to_a).to eq [status2] + account.block!(status_foo.account) + expect(query.filtered_for(account).to_a).to eq [status_bar] end it 'filters out muted accounts' do - account.mute!(status2.account) - expect(query.filtered_for(account).to_a).to eq [status1] + account.mute!(status_bar.account) + expect(query.filtered_for(account).to_a).to eq [status_foo] end it 'filters out blocked-by accounts' do - status1.account.block!(account) - expect(query.filtered_for(account).to_a).to eq [status2] + status_foo.account.block!(account) + expect(query.filtered_for(account).to_a).to eq [status_bar] end end end @@ -69,36 +71,35 @@ RSpec.describe Trends::Statuses do let!(:today) { at_time } let!(:yesterday) { today - 1.day } - let!(:status1) { Fabricate(:status, text: 'Foo', language: 'en', trendable: true, created_at: yesterday) } - let!(:status2) { Fabricate(:status, text: 'Bar', language: 'en', trendable: true, created_at: today) } - let!(:status3) { Fabricate(:status, text: 'Baz', language: 'en', trendable: true, created_at: today) } + let!(:status_foo) { Fabricate(:status, text: 'Foo', language: 'en', trendable: true, created_at: yesterday) } + let!(:status_bar) { Fabricate(:status, text: 'Bar', language: 'en', trendable: true, created_at: today) } + let!(:status_baz) { Fabricate(:status, text: 'Baz', language: 'en', trendable: true, created_at: today) } before do - 13.times { reblog(status1, today) } - 13.times { reblog(status2, today) } - 4.times { reblog(status3, today) } + default_threshold_value.times { reblog(status_foo, today) } + default_threshold_value.times { reblog(status_bar, today) } + (default_threshold_value - 1).times { reblog(status_baz, today) } end - context do + context 'when status trends are refreshed' do before do subject.refresh(today) end - it 'calculates and re-calculates scores' do - expect(subject.query.limit(10).to_a).to eq [status2, status1] - end + it 'returns correct statuses from query' do + results = subject.query.limit(10).to_a - it 'omits statuses below threshold' do - expect(subject.query.limit(10).to_a).to_not include(status3) + expect(results).to eq [status_bar, status_foo] + expect(results).to_not include(status_baz) end end it 'decays scores' do subject.refresh(today) - original_score = status2.trend.score + original_score = status_bar.trend.score expect(original_score).to be_a Float subject.refresh(today + subject.options[:score_halflife]) - decayed_score = status2.trend.reload.score + decayed_score = status_bar.trend.reload.score expect(decayed_score).to be <= original_score / 2 end end @@ -107,4 +108,8 @@ RSpec.describe Trends::Statuses do reblog = Fabricate(:status, reblog: status, created_at: at_time) subject.add(status, reblog.account_id, at_time) end + + def default_threshold_value + described_class.default_options[:threshold] + end end diff --git a/spec/models/trends/tags_spec.rb b/spec/models/trends/tags_spec.rb index f48c735035a..f2818fca87b 100644 --- a/spec/models/trends/tags_spec.rb +++ b/spec/models/trends/tags_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe Trends::Tags do @@ -22,45 +24,47 @@ RSpec.describe Trends::Tags do end describe '#query' do - pending + it 'returns a composable query scope' do + expect(subject.query).to be_a Trends::Query + end end describe '#refresh' do let!(:today) { at_time } let!(:yesterday) { today - 1.day } - let!(:tag1) { Fabricate(:tag, name: 'Catstodon', trendable: true) } - let!(:tag2) { Fabricate(:tag, name: 'DogsOfMastodon', trendable: true) } - let!(:tag3) { Fabricate(:tag, name: 'OCs', trendable: true) } + let!(:tag_cats) { Fabricate(:tag, name: 'Catstodon', trendable: true) } + let!(:tag_dogs) { Fabricate(:tag, name: 'DogsOfMastodon', trendable: true) } + let!(:tag_ocs) { Fabricate(:tag, name: 'OCs', trendable: true) } before do - 2.times { |i| subject.add(tag1, i, yesterday) } - 13.times { |i| subject.add(tag3, i, yesterday) } - 16.times { |i| subject.add(tag1, i, today) } - 4.times { |i| subject.add(tag2, i, today) } + 2.times { |i| subject.add(tag_cats, i, yesterday) } + 13.times { |i| subject.add(tag_ocs, i, yesterday) } + 16.times { |i| subject.add(tag_cats, i, today) } + 4.times { |i| subject.add(tag_dogs, i, today) } end - context do + context 'when tag trends are refreshed' do before do subject.refresh(yesterday + 12.hours) subject.refresh(at_time) end it 'calculates and re-calculates scores' do - expect(subject.query.limit(10).to_a).to eq [tag1, tag3] + expect(subject.query.limit(10).to_a).to eq [tag_cats, tag_ocs] end it 'omits hashtags below threshold' do - expect(subject.query.limit(10).to_a).to_not include(tag2) + expect(subject.query.limit(10).to_a).to_not include(tag_dogs) end end it 'decays scores' do subject.refresh(yesterday + 12.hours) - original_score = subject.score(tag3.id) + original_score = subject.score(tag_ocs.id) expect(original_score).to eq 144.0 subject.refresh(yesterday + 12.hours + subject.options[:max_score_halflife]) - decayed_score = subject.score(tag3.id) + decayed_score = subject.score(tag_ocs.id) expect(decayed_score).to be <= original_score / 2 end end diff --git a/spec/models/unavailable_domain_spec.rb b/spec/models/unavailable_domain_spec.rb deleted file mode 100644 index 3f2621034c5..00000000000 --- a/spec/models/unavailable_domain_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe UnavailableDomain, type: :model 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 1be38d8a477..00000000000 --- a/spec/models/user_invite_request_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe UserInviteRequest, type: :model do -end diff --git a/spec/models/user_role_spec.rb b/spec/models/user_role_spec.rb index 52a8622f998..f7cfe9bb043 100644 --- a/spec/models/user_role_spec.rb +++ b/spec/models/user_role_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe UserRole, type: :model do +RSpec.describe UserRole do subject { described_class.create(name: 'Foo', position: 1) } describe '#can?' do @@ -91,7 +93,7 @@ RSpec.describe UserRole, type: :model 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 @@ -99,7 +101,7 @@ RSpec.describe UserRole, type: :model 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 @@ -116,10 +118,8 @@ RSpec.describe UserRole, type: :model do end end - context do - it 'returns permissions combined with the everyone role' do - expect(subject.computed_permissions).to eq described_class.everyone.permissions - end + it 'returns permissions combined with the everyone role' do + expect(subject.computed_permissions).to eq described_class.everyone.permissions end end diff --git a/spec/models/user_settings/namespace_spec.rb b/spec/models/user_settings/namespace_spec.rb new file mode 100644 index 00000000000..ae2fa7b482c --- /dev/null +++ b/spec/models/user_settings/namespace_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UserSettings::Namespace do + subject { described_class.new(name) } + + let(:name) { :foo } + + describe '#setting' do + before do + subject.setting :bar, default: 'baz' + end + + it 'adds setting to definitions' do + expect(subject.definitions[:'foo.bar']).to have_attributes(name: :bar, namespace: :foo, default_value: 'baz') + end + end + + describe '#definitions' do + it 'returns a hash' do + expect(subject.definitions).to be_a Hash + end + end +end diff --git a/spec/models/user_settings/setting_spec.rb b/spec/models/user_settings/setting_spec.rb new file mode 100644 index 00000000000..8c8d31ec541 --- /dev/null +++ b/spec/models/user_settings/setting_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UserSettings::Setting do + subject { described_class.new(name, options) } + + let(:name) { :foo } + let(:options) { { default: default, namespace: namespace } } + let(:default) { false } + let(:namespace) { nil } + + describe '#default_value' do + context 'when default value is a primitive value' do + it 'returns default value' do + expect(subject.default_value).to eq default + end + end + + context 'when default value is a proc' do + let(:default) { -> { 'bar' } } + + it 'returns value from proc' do + expect(subject.default_value).to eq 'bar' + end + end + end + + describe '#type' do + it 'returns a type' do + expect(subject.type).to be_a ActiveModel::Type::Value + end + + context 'when default value is a boolean' do + let(:default) { false } + + it 'returns boolean' do + expect(subject.type).to be_a ActiveModel::Type::Boolean + end + end + + context 'when default value is a string' do + let(:default) { '' } + + it 'returns string' do + expect(subject.type).to be_a ActiveModel::Type::String + end + end + + context 'when default value is a lambda returning a boolean' do + let(:default) { -> { false } } + + it 'returns boolean' do + expect(subject.type).to be_a ActiveModel::Type::Boolean + end + end + + context 'when default value is a lambda returning a string' do + let(:default) { -> { '' } } + + it 'returns boolean' do + expect(subject.type).to be_a ActiveModel::Type::String + end + end + end + + describe '#type_cast' do + context 'when default value is a boolean' do + let(:default) { false } + + it 'returns boolean' do + expect(subject.type_cast('1')).to be true + end + end + + context 'when default value is a string' do + let(:default) { '' } + + it 'returns string' do + expect(subject.type_cast(1)).to eq '1' + end + end + end + + describe '#to_a' do + it 'returns an array' do + expect(subject.to_a).to eq [name, default] + end + end + + describe '#key' do + context 'when there is no namespace' do + it 'returns a symbol' do + expect(subject.key).to eq :foo + end + end + + context 'when there is a namespace' do + let(:namespace) { :bar } + + it 'returns a symbol' do + expect(subject.key).to eq :'bar.foo' + end + end + end +end diff --git a/spec/models/user_settings_spec.rb b/spec/models/user_settings_spec.rb new file mode 100644 index 00000000000..653597c90de --- /dev/null +++ b/spec/models/user_settings_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UserSettings do + subject { described_class.new(json) } + + let(:json) { {} } + + describe '#[]' do + context 'when setting is not set' do + it 'returns default value' do + expect(subject[:always_send_emails]).to be false + end + end + + context 'when setting is set' do + let(:json) { { default_language: 'fr' } } + + it 'returns value' do + expect(subject[:default_language]).to eq 'fr' + end + end + + context 'when setting was not defined' do + it 'raises error' do + expect { subject[:foo] }.to raise_error UserSettings::KeyError + end + end + end + + describe '#[]=' do + context 'when value matches type' do + before do + subject[:always_send_emails] = true + end + + it 'updates value' do + expect(subject[:always_send_emails]).to be true + end + end + + context 'when value needs to be type-cast' do + before do + subject[:always_send_emails] = '1' + end + + it 'updates value with a type-cast' do + expect(subject[:always_send_emails]).to be true + end + end + + context 'when the setting has a closed set of values' do + it 'updates the attribute when given a valid value' do + expect { subject[:'web.display_media'] = :show_all }.to change { subject[:'web.display_media'] }.from('default').to('show_all') + end + + it 'raises an error when given an invalid value' do + expect { subject[:'web.display_media'] = 'invalid value' }.to raise_error ArgumentError + end + end + end + + describe '#update' do + before do + subject.update(always_send_emails: true, default_language: 'fr', default_privacy: nil) + end + + it 'updates values' do + expect(subject[:always_send_emails]).to be true + expect(subject[:default_language]).to eq 'fr' + end + + it 'does not set values that are nil' do + expect(subject.as_json).to_not include(default_privacy: nil) + end + end + + describe '#as_json' do + let(:json) { { default_language: 'fr' } } + + it 'returns hash' do + expect(subject.as_json).to eq json + end + end + + describe '.keys' do + it 'returns an array' do + expect(described_class.keys).to be_a Array + end + end + + describe '.definition_for' do + context 'when key is defined' do + it 'returns a setting' do + expect(described_class.definition_for(:always_send_emails)).to be_a UserSettings::Setting + end + end + + context 'when key is not defined' do + it 'returns nil' do + expect(described_class.definition_for(:foo)).to be_nil + end + end + end + + describe '.definition_for?' do + context 'when key is defined' do + it 'returns true' do + expect(described_class.definition_for?(:always_send_emails)).to be true + end + end + + context 'when key is not defined' do + it 'returns false' do + expect(described_class.definition_for?(:foo)).to be false + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 9dfd6678ac7..92ce87e3696 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' require 'devise_two_factor/spec_helpers' -RSpec.describe User, type: :model do +RSpec.describe User do let(:password) { 'abcd1234' } let(:account) { Fabricate(:account, username: 'alice') } @@ -53,17 +55,17 @@ RSpec.describe User, type: :model do describe 'scopes' do describe 'recent' do it 'returns an array of recent users ordered by id' do - user_1 = Fabricate(:user) - user_2 = Fabricate(:user) - expect(User.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(User.confirmed).to match_array([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 @@ -72,7 +74,7 @@ RSpec.describe User, type: :model do specified = Fabricate(:user, current_sign_in_at: 15.days.ago) Fabricate(:user, current_sign_in_at: 6.days.ago) - expect(User.inactive).to match_array([specified]) + expect(described_class.inactive).to contain_exactly(specified) end end @@ -81,7 +83,7 @@ RSpec.describe User, type: :model do specified = Fabricate(:user, email: 'specified@spec') Fabricate(:user, email: 'unspecified@spec') - expect(User.matches_email('specified')).to match_array([specified]) + expect(described_class.matches_email('specified')).to contain_exactly(specified) end end @@ -94,7 +96,7 @@ RSpec.describe User, type: :model do Fabricate(:session_activation, user: user2, ip: '2160:8888::24', session_id: '3') Fabricate(:session_activation, user: user2, ip: '2160:8888::25', session_id: '4') - expect(User.matches_ip('2160:2160::/32')).to match_array([user1]) + expect(described_class.matches_ip('2160:2160::/32')).to contain_exactly(user1) end end end @@ -111,21 +113,21 @@ RSpec.describe User, type: :model do end it 'allows a non-blacklisted user to be created' do - user = User.new(email: 'foo@example.com', account: account, password: password, agreement: true) + user = described_class.new(email: 'foo@example.com', account: account, password: password, agreement: true) - expect(user.valid?).to be_truthy + expect(user).to be_valid end it 'does not allow a blacklisted user to be created' do - user = User.new(email: 'foo@mvrht.com', account: account, password: password, agreement: true) + user = described_class.new(email: 'foo@mvrht.com', account: account, password: password, agreement: true) - expect(user.valid?).to be_falsey + expect(user).to_not be_valid end it 'does not allow a subdomain blacklisted user to be created' do - user = User.new(email: 'foo@mvrht.com.topdomain.tld', account: account, password: password, agreement: true) + user = described_class.new(email: 'foo@mvrht.com.topdomain.tld', account: account, password: password, agreement: true) - expect(user.valid?).to be_falsey + expect(user).to_not be_valid end end @@ -311,9 +313,9 @@ RSpec.describe User, type: :model do end describe 'settings' do - it 'is instance of Settings::ScopedSettings' do + it 'is instance of UserSettings' do user = Fabricate(:user) - expect(user.settings).to be_a Settings::ScopedSettings + expect(user.settings).to be_a UserSettings end end @@ -347,21 +349,21 @@ RSpec.describe User, type: :model do end it 'does not allow a user to be created unless they are whitelisted' do - user = User.new(email: 'foo@example.com', account: account, password: password, agreement: true) - expect(user.valid?).to be_falsey + user = described_class.new(email: 'foo@example.com', account: account, password: password, agreement: true) + expect(user).to_not be_valid end it 'allows a user to be created if they are whitelisted' do - user = User.new(email: 'foo@mastodon.space', account: account, password: password, agreement: true) - expect(user.valid?).to be_truthy + user = described_class.new(email: 'foo@mastodon.space', account: account, password: password, agreement: true) + expect(user).to be_valid end it 'does not allow a user with a whitelisted top domain as subdomain in their email address to be created' do - user = User.new(email: 'foo@mastodon.space.userdomain.com', account: account, password: password, agreement: true) - expect(user.valid?).to be_falsey + user = described_class.new(email: 'foo@mastodon.space.userdomain.com', account: account, password: password, agreement: true) + expect(user).to_not be_valid end - context do + context 'with a blacklisted subdomain' do around do |example| old_blacklist = Rails.configuration.x.email_blacklist example.run @@ -371,22 +373,12 @@ RSpec.describe User, type: :model do it 'does not allow a user to be created with a specific blacklisted subdomain even if the top domain is whitelisted' do Rails.configuration.x.email_domains_blacklist = 'blacklisted.mastodon.space' - user = User.new(email: 'foo@blacklisted.mastodon.space', account: account, password: password) - expect(user.valid?).to be_falsey + user = described_class.new(email: 'foo@blacklisted.mastodon.space', account: account, password: password) + expect(user).to_not be_valid end end end - it_behaves_like 'Settings-extended' do - def create! - User.create!(account: Fabricate(:account, user: nil), email: 'foo@mastodon.space', password: 'abcd1234', agreement: true) - end - - def fabricate - Fabricate(:user) - end - end - describe 'token_for_app' do let(:user) { Fabricate(:user) } let(:app) { Fabricate(:application, owner: user) } @@ -535,6 +527,28 @@ RSpec.describe User, type: :model do end describe '.those_who_can' do - pending + before { Fabricate(:user, role: UserRole.find_by(name: 'Moderator')) } + + context 'when there are not any user roles' do + before { UserRole.destroy_all } + + it 'returns an empty list' do + expect(described_class.those_who_can(:manage_blocks)).to eq([]) + end + end + + context 'when there are not users with the needed role' do + it 'returns an empty list' do + expect(described_class.those_who_can(:manage_blocks)).to eq([]) + end + end + + context 'when there are users with roles' do + let!(:admin_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } + + it 'returns the users with the role' do + expect(described_class.those_who_can(:manage_blocks)).to eq([admin_user]) + end + end end end diff --git a/spec/models/web/push_subscription_spec.rb b/spec/models/web/push_subscription_spec.rb index a5c34f4edcc..3c2cd3bac1b 100644 --- a/spec/models/web/push_subscription_spec.rb +++ b/spec/models/web/push_subscription_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Web::PushSubscription, type: :model do +RSpec.describe Web::PushSubscription do subject { described_class.new(data: data) } let(:account) { Fabricate(:account) } @@ -54,7 +56,7 @@ RSpec.describe Web::PushSubscription, type: :model do context 'when policy is followed' do let(:policy) { 'followed' } - context 'and notification is from someone you follow' do + context 'when notification is from someone you follow' do before do account.follow!(notification.from_account) end @@ -64,7 +66,7 @@ RSpec.describe Web::PushSubscription, type: :model do end end - context 'and notification is not from someone you follow' do + context 'when notification is not from someone you follow' do it 'returns false' do expect(subject.pushable?(notification)).to be false end @@ -74,7 +76,7 @@ RSpec.describe Web::PushSubscription, type: :model do context 'when policy is follower' do let(:policy) { 'follower' } - context 'and notification is from someone who follows you' do + context 'when notification is from someone who follows you' do before do notification.from_account.follow!(account) end @@ -84,7 +86,7 @@ RSpec.describe Web::PushSubscription, type: :model do end end - context 'and notification is not from someone who follows you' do + context 'when notification is not from someone who follows you' do it 'returns false' do expect(subject.pushable?(notification)).to be false end diff --git a/spec/models/web/setting_spec.rb b/spec/models/web/setting_spec.rb deleted file mode 100644 index 6657d4030f4..00000000000 --- a/spec/models/web/setting_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' - -RSpec.describe Web::Setting, type: :model do -end diff --git a/spec/models/webauthn_credentials_spec.rb b/spec/models/webauthn_credentials_spec.rb index e070a6b60e4..9631245e11b 100644 --- a/spec/models/webauthn_credentials_spec.rb +++ b/spec/models/webauthn_credentials_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe WebauthnCredential, type: :model do +RSpec.describe WebauthnCredential do describe 'validations' do it 'is invalid without an external id' do webauthn_credential = Fabricate.build(:webauthn_credential, external_id: nil) @@ -35,7 +37,7 @@ RSpec.describe WebauthnCredential, type: :model 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? @@ -45,7 +47,7 @@ RSpec.describe WebauthnCredential, type: :model 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 60c3d9524fc..715dd7574ff 100644 --- a/spec/models/webhook_spec.rb +++ b/spec/models/webhook_spec.rb @@ -1,6 +1,8 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Webhook, type: :model do +RSpec.describe Webhook do let(:webhook) { Fabricate(:webhook) } describe '#rotate_secret!' do diff --git a/spec/policies/account_moderation_note_policy_spec.rb b/spec/policies/account_moderation_note_policy_spec.rb index 8467473465e..8c37acc39fe 100644 --- a/spec/policies/account_moderation_note_policy_spec.rb +++ b/spec/policies/account_moderation_note_policy_spec.rb @@ -4,20 +4,21 @@ 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) } permissions :create? do - context 'staff' do + context 'when staff' do it 'grants to create' do - expect(subject).to permit(admin, AccountModerationNotePolicy) + expect(subject).to permit(admin, described_class) end end - context 'not staff' do + context 'when not staff' do it 'denies to create' do - expect(subject).to_not permit(john, AccountModerationNotePolicy) + expect(subject).to_not permit(john, described_class) end end end @@ -29,19 +30,19 @@ RSpec.describe AccountModerationNotePolicy do target_account: Fabricate(:account)) end - context 'admin' do + context 'when admin' do it 'grants to destroy' do expect(subject).to permit(admin, account_moderation_note) end end - context 'owner' do + context 'when owner' do it 'grants to destroy' do expect(subject).to permit(john, account_moderation_note) end end - context 'neither admin nor owner' do + context 'when neither admin nor owner' do let(:kevin) { Fabricate(:account) } it 'denies to destroy' do diff --git a/spec/policies/account_policy_spec.rb b/spec/policies/account_policy_spec.rb index 0f23fd97e28..d7a21d8e39a 100644 --- a/spec/policies/account_policy_spec.rb +++ b/spec/policies/account_policy_spec.rb @@ -4,19 +4,20 @@ 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) } permissions :index? do - context 'staff' do + context 'when staff' do it 'permits' do expect(subject).to permit(admin) end end - context 'not staff' do + context 'when not staff' do it 'denies' do expect(subject).to_not permit(john) end @@ -24,13 +25,13 @@ RSpec.describe AccountPolicy do end permissions :show?, :unsilence?, :unsensitive?, :remove_avatar?, :remove_header? do - context 'staff' do + context 'when staff' do it 'permits' do expect(subject).to permit(admin, alice) end end - context 'not staff' do + context 'when not staff' do it 'denies' do expect(subject).to_not permit(john, alice) end @@ -42,13 +43,13 @@ RSpec.describe AccountPolicy do alice.suspend! end - context 'staff' do + context 'when staff' do it 'permits' do expect(subject).to permit(admin, alice) end end - context 'not staff' do + context 'when not staff' do it 'denies' do expect(subject).to_not permit(john, alice) end @@ -56,13 +57,13 @@ RSpec.describe AccountPolicy do end permissions :redownload? do - context 'admin' do + context 'when admin' do it 'permits' do expect(subject).to permit(admin) end end - context 'not admin' do + context 'when not admin' do it 'denies' do expect(subject).to_not permit(john) end @@ -72,21 +73,21 @@ RSpec.describe AccountPolicy do permissions :suspend?, :silence? do let(:staff) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } - context 'staff' do - context 'record is staff' do + context 'when staff' do + context 'when record is staff' do it 'denies' do expect(subject).to_not permit(admin, staff) end end - context 'record is not staff' do + context 'when record is not staff' do it 'permits' do expect(subject).to permit(admin, john) end end end - context 'not staff' do + context 'when not staff' do it 'denies' do expect(subject).to_not permit(john, Account) end @@ -96,24 +97,64 @@ RSpec.describe AccountPolicy do permissions :memorialize? do let(:other_admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } - context 'admin' do - context 'record is admin' do + context 'when admin' do + context 'when record is admin' do it 'denies' do expect(subject).to_not permit(admin, other_admin) end end - context 'record is not admin' do + context 'when record is not admin' do it 'permits' do expect(subject).to permit(admin, john) end end end - context 'not admin' do + context 'when not admin' do it 'denies' do expect(subject).to_not permit(john, Account) end end end + + permissions :review? do + context 'when admin' do + it 'permits' do + expect(subject).to permit(admin) + end + end + + context 'when not admin' do + it 'denies' do + expect(subject).to_not permit(john) + end + end + end + + permissions :destroy? do + context 'when admin' do + context 'with a temporarily suspended account' do + before { allow(alice).to receive(:suspended_temporarily?).and_return(true) } + + it 'permits' do + expect(subject).to permit(admin, alice) + end + end + + context 'with a not temporarily suspended account' do + before { allow(alice).to receive(:suspended_temporarily?).and_return(false) } + + it 'denies' do + expect(subject).to_not permit(admin, alice) + end + end + end + + context 'when not admin' do + it 'denies' do + expect(subject).to_not permit(john, alice) + end + end + end end diff --git a/spec/policies/account_warning_preset_policy_spec.rb b/spec/policies/account_warning_preset_policy_spec.rb new file mode 100644 index 00000000000..63bf33de249 --- /dev/null +++ b/spec/policies/account_warning_preset_policy_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +describe AccountWarningPresetPolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:account) } + + permissions :index?, :create?, :update?, :destroy? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Tag) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Tag) + end + end + end +end diff --git a/spec/policies/admin/status_policy_spec.rb b/spec/policies/admin/status_policy_spec.rb new file mode 100644 index 00000000000..af9f7716be3 --- /dev/null +++ b/spec/policies/admin/status_policy_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +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, visibility: status_visibility) } + let(:status_visibility) { :public } + + permissions :index?, :update?, :review?, :destroy? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Tag) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Tag) + end + end + end + + permissions :show? do + context 'with an admin' do + context 'with a public visible status' do + let(:status_visibility) { :public } + + it 'permits' do + expect(policy).to permit(admin, status) + end + end + + context 'with a not public visible status' do + 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 + + context 'with a non admin' do + it 'denies' do + expect(policy).to_not permit(john, status) + end + end + end +end diff --git a/spec/policies/announcement_policy_spec.rb b/spec/policies/announcement_policy_spec.rb new file mode 100644 index 00000000000..3d230b3cb46 --- /dev/null +++ b/spec/policies/announcement_policy_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +describe AnnouncementPolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:account) } + + permissions :index?, :create?, :update?, :destroy? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Tag) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Tag) + end + end + end +end diff --git a/spec/policies/appeal_policy_spec.rb b/spec/policies/appeal_policy_spec.rb new file mode 100644 index 00000000000..d7498eb9f09 --- /dev/null +++ b/spec/policies/appeal_policy_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +describe AppealPolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:account) } + let(:appeal) { Fabricate(:appeal) } + + permissions :index? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Tag) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Tag) + end + end + end + + permissions :reject? do + context 'with an admin' do + context 'with a pending appeal' do + before { allow(appeal).to receive(:pending?).and_return(true) } + + it 'permits' do + expect(policy).to permit(admin, appeal) + end + end + + context 'with a not pending appeal' do + before { allow(appeal).to receive(:pending?).and_return(false) } + + it 'denies' do + expect(policy).to_not permit(admin, appeal) + end + end + end + + context 'with a non admin' do + it 'denies' do + expect(policy).to_not permit(john, appeal) + end + end + end +end diff --git a/spec/policies/backup_policy_spec.rb b/spec/policies/backup_policy_spec.rb index 6b31c6f7c7f..28cb65d7890 100644 --- a/spec/policies/backup_policy_spec.rb +++ b/spec/policies/backup_policy_spec.rb @@ -4,24 +4,25 @@ 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 'not user_signed_in?' do + context 'when not user_signed_in?' do it 'denies' do expect(subject).to_not permit(nil, Backup) end end - context 'user_signed_in?' do - context 'no backups' do + context 'when user_signed_in?' do + context 'with no backups' do it 'permits' do expect(subject).to permit(john, Backup) end end - context 'backups are too old' do + context 'when backups are too old' do it 'permits' do travel(-8.days) do Fabricate(:backup, user: john.user) @@ -31,7 +32,7 @@ RSpec.describe BackupPolicy do end end - context 'backups are newer' do + context 'when backups are newer' do it 'denies' do travel(-3.days) do Fabricate(:backup, user: john.user) diff --git a/spec/policies/canonical_email_block_policy_spec.rb b/spec/policies/canonical_email_block_policy_spec.rb new file mode 100644 index 00000000000..0e55febfa90 --- /dev/null +++ b/spec/policies/canonical_email_block_policy_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +describe CanonicalEmailBlockPolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:account) } + + permissions :index?, :show?, :test?, :create?, :destroy? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Tag) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Tag) + end + end + end +end diff --git a/spec/policies/custom_emoji_policy_spec.rb b/spec/policies/custom_emoji_policy_spec.rb index 6a6ef6694d7..cb869c7d9a7 100644 --- a/spec/policies/custom_emoji_policy_spec.rb +++ b/spec/policies/custom_emoji_policy_spec.rb @@ -4,18 +4,19 @@ 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) } permissions :index?, :enable?, :disable? do - context 'staff' do + context 'when staff' do it 'permits' do expect(subject).to permit(admin, CustomEmoji) end end - context 'not staff' do + context 'when not staff' do it 'denies' do expect(subject).to_not permit(john, CustomEmoji) end @@ -23,13 +24,13 @@ RSpec.describe CustomEmojiPolicy do end permissions :create?, :update?, :copy?, :destroy? do - context 'admin' do + context 'when admin' do it 'permits' do expect(subject).to permit(admin, CustomEmoji) end end - context 'not admin' do + context 'when not admin' do it 'denies' do expect(subject).to_not permit(john, CustomEmoji) end diff --git a/spec/policies/delivery_policy_spec.rb b/spec/policies/delivery_policy_spec.rb new file mode 100644 index 00000000000..fbcbf390d73 --- /dev/null +++ b/spec/policies/delivery_policy_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +describe DeliveryPolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:account) } + + permissions :clear_delivery_errors?, :restart_delivery?, :stop_delivery? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Tag) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Tag) + end + end + end +end diff --git a/spec/policies/domain_block_policy_spec.rb b/spec/policies/domain_block_policy_spec.rb index 01b97e823a2..4c89f3f3742 100644 --- a/spec/policies/domain_block_policy_spec.rb +++ b/spec/policies/domain_block_policy_spec.rb @@ -4,18 +4,19 @@ 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) } permissions :index?, :show?, :create?, :destroy? do - context 'admin' do + context 'when admin' do it 'permits' do expect(subject).to permit(admin, DomainBlock) end end - context 'not admin' do + context 'when not admin' do it 'denies' do expect(subject).to_not permit(john, DomainBlock) end diff --git a/spec/policies/email_domain_block_policy_spec.rb b/spec/policies/email_domain_block_policy_spec.rb index 913075c3d28..7ecff4be499 100644 --- a/spec/policies/email_domain_block_policy_spec.rb +++ b/spec/policies/email_domain_block_policy_spec.rb @@ -4,18 +4,19 @@ 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) } - permissions :index?, :create?, :destroy? do - context 'admin' do + permissions :index?, :show?, :create?, :destroy? do + context 'when admin' do it 'permits' do expect(subject).to permit(admin, EmailDomainBlock) end end - context 'not admin' do + context 'when not admin' do it 'denies' do expect(subject).to_not permit(john, EmailDomainBlock) end diff --git a/spec/policies/follow_recommendation_policy_spec.rb b/spec/policies/follow_recommendation_policy_spec.rb new file mode 100644 index 00000000000..01f4da0be29 --- /dev/null +++ b/spec/policies/follow_recommendation_policy_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +describe FollowRecommendationPolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:account) } + + permissions :show?, :suppress?, :unsuppress? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Tag) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Tag) + end + end + end +end diff --git a/spec/policies/instance_policy_spec.rb b/spec/policies/instance_policy_spec.rb index f6f51af068b..a0d9a008b7b 100644 --- a/spec/policies/instance_policy_spec.rb +++ b/spec/policies/instance_policy_spec.rb @@ -4,18 +4,19 @@ 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) } permissions :index?, :show?, :destroy? do - context 'admin' do + context 'when admin' do it 'permits' do expect(subject).to permit(admin, Instance) end end - context 'not admin' do + context 'when not admin' do it 'denies' do expect(subject).to_not permit(john, Instance) end diff --git a/spec/policies/invite_policy_spec.rb b/spec/policies/invite_policy_spec.rb index 01660322f1e..cbe3735d806 100644 --- a/spec/policies/invite_policy_spec.rb +++ b/spec/policies/invite_policy_spec.rb @@ -4,12 +4,13 @@ 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 } permissions :index? do - context 'staff?' do + context 'when staff?' do it 'permits' do expect(subject).to permit(admin, Invite) end @@ -17,7 +18,7 @@ RSpec.describe InvitePolicy do end permissions :create? do - context 'has privilege' do + context 'with privilege' do before do UserRole.everyone.update(permissions: UserRole::FLAGS[:invite_users]) end @@ -27,7 +28,7 @@ RSpec.describe InvitePolicy do end end - context 'does not have privilege' do + context 'when does not have privilege' do before do UserRole.everyone.update(permissions: UserRole::Flags::NONE) end @@ -39,13 +40,13 @@ RSpec.describe InvitePolicy do end permissions :deactivate_all? do - context 'admin?' do + context 'when admin?' do it 'permits' do expect(subject).to permit(admin, Invite) end end - context 'not admin?' do + context 'when not admin?' do it 'denies' do expect(subject).to_not permit(john, Invite) end @@ -53,20 +54,20 @@ RSpec.describe InvitePolicy do end permissions :destroy? do - context 'owner?' do + context 'when owner?' do it 'permits' do expect(subject).to permit(john, Fabricate(:invite, user: john.user)) end end - context 'not owner?' do - context 'admin?' do + context 'when not owner?' do + context 'when admin?' do it 'permits' do expect(subject).to permit(admin, Fabricate(:invite)) end end - context 'not admin?' do + context 'when not admin?' do it 'denies' do expect(subject).to_not permit(john, Fabricate(:invite)) end diff --git a/spec/policies/ip_block_policy_spec.rb b/spec/policies/ip_block_policy_spec.rb new file mode 100644 index 00000000000..3cfa85863ca --- /dev/null +++ b/spec/policies/ip_block_policy_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +describe IpBlockPolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:account) } + + permissions :index?, :show?, :create?, :update?, :destroy? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Tag) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Tag) + end + end + end +end diff --git a/spec/policies/preview_card_policy_spec.rb b/spec/policies/preview_card_policy_spec.rb new file mode 100644 index 00000000000..d6675c5b341 --- /dev/null +++ b/spec/policies/preview_card_policy_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +describe PreviewCardPolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:account) } + + permissions :index?, :review? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Tag) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Tag) + end + end + end +end diff --git a/spec/policies/preview_card_provider_policy_spec.rb b/spec/policies/preview_card_provider_policy_spec.rb new file mode 100644 index 00000000000..8d3715de955 --- /dev/null +++ b/spec/policies/preview_card_provider_policy_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +describe PreviewCardProviderPolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:account) } + + permissions :index?, :review? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Tag) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Tag) + end + end + end +end diff --git a/spec/policies/relay_policy_spec.rb b/spec/policies/relay_policy_spec.rb index 2c50ba1e9fe..29ba02c26a8 100644 --- a/spec/policies/relay_policy_spec.rb +++ b/spec/policies/relay_policy_spec.rb @@ -4,18 +4,19 @@ 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) } permissions :update? do - context 'admin?' do + context 'when admin?' do it 'permits' do expect(subject).to permit(admin, Relay) end end - context '!admin?' do + context 'with !admin?' do it 'denies' do expect(subject).to_not permit(john, Relay) end diff --git a/spec/policies/report_note_policy_spec.rb b/spec/policies/report_note_policy_spec.rb index 99f5ffb8e38..b40a8788875 100644 --- a/spec/policies/report_note_policy_spec.rb +++ b/spec/policies/report_note_policy_spec.rb @@ -4,18 +4,19 @@ 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) } permissions :create? do - context 'staff?' do + context 'when staff?' do it 'permits' do expect(subject).to permit(admin, ReportNote) end end - context '!staff?' do + context 'with !staff?' do it 'denies' do expect(subject).to_not permit(john, ReportNote) end @@ -23,26 +24,24 @@ RSpec.describe ReportNotePolicy do end permissions :destroy? do - context 'admin?' do + context 'when admin?' do it 'permit' do report_note = Fabricate(:report_note, account: john) expect(subject).to permit(admin, report_note) end end - context 'admin?' do - context 'owner?' do - it 'permit' do - report_note = Fabricate(:report_note, account: john) - expect(subject).to permit(john, report_note) - end + context 'when owner?' do + it 'permit' do + report_note = Fabricate(:report_note, account: john) + expect(subject).to permit(john, report_note) end + end - context '!owner?' do - it 'denies' do - report_note = Fabricate(:report_note) - expect(subject).to_not permit(john, report_note) - end + context 'with !owner?' do + it 'denies' do + report_note = Fabricate(:report_note) + expect(subject).to_not permit(john, report_note) end end end diff --git a/spec/policies/report_policy_spec.rb b/spec/policies/report_policy_spec.rb index 8b005d8ddd0..4fc41780758 100644 --- a/spec/policies/report_policy_spec.rb +++ b/spec/policies/report_policy_spec.rb @@ -4,18 +4,19 @@ 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) } permissions :update?, :index?, :show? do - context 'staff?' do + context 'when staff?' do it 'permits' do expect(subject).to permit(admin, Report) end end - context '!staff?' do + context 'with !staff?' do it 'denies' do expect(subject).to_not permit(john, Report) end diff --git a/spec/policies/rule_policy_spec.rb b/spec/policies/rule_policy_spec.rb new file mode 100644 index 00000000000..0e45f6df02f --- /dev/null +++ b/spec/policies/rule_policy_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +describe RulePolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:account) } + + permissions :index?, :create?, :update?, :destroy? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Tag) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Tag) + end + end + end +end diff --git a/spec/policies/settings_policy_spec.rb b/spec/policies/settings_policy_spec.rb index e16ee51a485..4a993149052 100644 --- a/spec/policies/settings_policy_spec.rb +++ b/spec/policies/settings_policy_spec.rb @@ -4,18 +4,19 @@ 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) } - permissions :update?, :show? do - context 'admin?' do + permissions :update?, :show?, :destroy? do + context 'when admin?' do it 'permits' do expect(subject).to permit(admin, Settings) end end - context '!admin?' do + context 'with !admin?' do it 'denies' do expect(subject).to_not permit(john, Settings) end diff --git a/spec/policies/software_update_policy_spec.rb b/spec/policies/software_update_policy_spec.rb new file mode 100644 index 00000000000..e19ba616128 --- /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/status_policy_spec.rb b/spec/policies/status_policy_spec.rb index b88521708a1..36ac8d8027a 100644 --- a/spec/policies/status_policy_spec.rb +++ b/spec/policies/status_policy_spec.rb @@ -11,119 +11,139 @@ RSpec.describe StatusPolicy, type: :model do let(:bob) { Fabricate(:account, username: 'bob') } let(:status) { Fabricate(:status, account: alice) } - permissions :show?, :reblog? do - it 'grants access when no viewer' do - expect(subject).to permit(nil, status) - end + context 'with the permissions of show? and reblog?' do + permissions :show?, :reblog? do + it 'grants access when no viewer' do + expect(subject).to permit(nil, status) + end - it 'denies access when viewer is blocked' do - block = Fabricate(:block) - status.visibility = :private - status.account = block.target_account + it 'denies access when viewer is blocked' do + block = Fabricate(:block) + status.visibility = :private + status.account = block.target_account - expect(subject).to_not permit(block.account, status) + expect(subject).to_not permit(block.account, status) + end end end - permissions :show? do - it 'grants access when direct and account is viewer' do - status.visibility = :direct + context 'with the permission of show?' do + permissions :show? do + it 'grants access when direct and account is viewer' do + status.visibility = :direct - expect(subject).to permit(status.account, status) - end + expect(subject).to permit(status.account, status) + end - it 'grants access when direct and viewer is mentioned' do - status.visibility = :direct - status.mentions = [Fabricate(:mention, account: alice)] + it 'grants access when direct and viewer is mentioned' do + status.visibility = :direct + status.mentions = [Fabricate(:mention, account: alice)] - expect(subject).to permit(alice, status) - end + expect(subject).to permit(alice, status) + end - it 'denies access when direct and viewer is not mentioned' do - viewer = Fabricate(:account) - status.visibility = :direct + it 'grants access when direct and non-owner viewer is mentioned and mentions are loaded' do + status.visibility = :direct + status.mentions = [Fabricate(:mention, account: bob)] + status.mentions.load - expect(subject).to_not permit(viewer, status) - end + expect(subject).to permit(bob, status) + end - it 'grants access when private and account is viewer' do - status.visibility = :private + it 'denies access when direct and viewer is not mentioned' do + viewer = Fabricate(:account) + status.visibility = :direct - expect(subject).to permit(status.account, status) - end + expect(subject).to_not permit(viewer, status) + end - it 'grants access when private and account is following viewer' do - follow = Fabricate(:follow) - status.visibility = :private - status.account = follow.target_account + it 'grants access when private and account is viewer' do + status.visibility = :private - expect(subject).to permit(follow.account, status) - end + expect(subject).to permit(status.account, status) + end - it 'grants access when private and viewer is mentioned' do - status.visibility = :private - status.mentions = [Fabricate(:mention, account: alice)] + it 'grants access when private and account is following viewer' do + follow = Fabricate(:follow) + status.visibility = :private + status.account = follow.target_account - expect(subject).to permit(alice, status) - end + expect(subject).to permit(follow.account, status) + end - it 'denies access when private and viewer is not mentioned or followed' do - viewer = Fabricate(:account) - status.visibility = :private + it 'grants access when private and viewer is mentioned' do + status.visibility = :private + status.mentions = [Fabricate(:mention, account: alice)] - expect(subject).to_not permit(viewer, status) + expect(subject).to permit(alice, status) + end + + it 'denies access when private and viewer is not mentioned or followed' do + viewer = Fabricate(:account) + status.visibility = :private + + expect(subject).to_not permit(viewer, status) + end end end - permissions :reblog? do - it 'denies access when private' do - viewer = Fabricate(:account) - status.visibility = :private + context 'with the permission of reblog?' do + permissions :reblog? do + it 'denies access when private' do + viewer = Fabricate(:account) + status.visibility = :private - expect(subject).to_not permit(viewer, status) - end + expect(subject).to_not permit(viewer, status) + end - it 'denies access when direct' do - viewer = Fabricate(:account) - status.visibility = :direct + it 'denies access when direct' do + viewer = Fabricate(:account) + status.visibility = :direct - expect(subject).to_not permit(viewer, status) + expect(subject).to_not permit(viewer, status) + end end end - permissions :destroy?, :unreblog? do - it 'grants access when account is deleter' do - expect(subject).to permit(status.account, status) - end + context 'with the permissions of destroy? and unreblog?' do + permissions :destroy?, :unreblog? do + it 'grants access when account is deleter' do + expect(subject).to permit(status.account, status) + end - it 'denies access when account is not deleter' do - expect(subject).to_not permit(bob, status) - end + it 'denies access when account is not deleter' do + expect(subject).to_not permit(bob, status) + end - it 'denies access when no deleter' do - expect(subject).to_not permit(nil, status) + it 'denies access when no deleter' do + expect(subject).to_not permit(nil, status) + end end end - permissions :favourite? do - it 'grants access when viewer is not blocked' do - follow = Fabricate(:follow) - status.account = follow.target_account + context 'with the permission of favourite?' do + permissions :favourite? do + it 'grants access when viewer is not blocked' do + follow = Fabricate(:follow) + status.account = follow.target_account - expect(subject).to permit(follow.account, status) - end + expect(subject).to permit(follow.account, status) + end - it 'denies when viewer is blocked' do - block = Fabricate(:block) - status.account = block.target_account + it 'denies when viewer is blocked' do + block = Fabricate(:block) + status.account = block.target_account - expect(subject).to_not permit(block.account, status) + expect(subject).to_not permit(block.account, status) + end end end - permissions :update? do - it 'grants access if owner' do - expect(subject).to permit(status.account, status) + context 'with the permission of update?' do + permissions :update? do + it 'grants access if owner' do + expect(subject).to permit(status.account, status) + end end end end diff --git a/spec/policies/tag_policy_spec.rb b/spec/policies/tag_policy_spec.rb index 9be7140fc2d..35da3cc62a0 100644 --- a/spec/policies/tag_policy_spec.rb +++ b/spec/policies/tag_policy_spec.rb @@ -4,18 +4,19 @@ 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) } - permissions :index?, :show?, :update? do - context 'staff?' do + permissions :index?, :show?, :update?, :review? do + context 'when staff?' do it 'permits' do expect(subject).to permit(admin, Tag) end end - context '!staff?' do + context 'with !staff?' do it 'denies' do expect(subject).to_not permit(john, Tag) end diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index ff0916674e5..fa476a9fc3d 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -4,26 +4,27 @@ 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) } permissions :reset_password?, :change_email? do - context 'staff?' do - context '!record.staff?' do + context 'when staff?' do + context 'with !record.staff?' do it 'permits' do expect(subject).to permit(admin, john.user) end end - context 'record.staff?' do + context 'when record.staff?' do it 'denies' do expect(subject).to_not permit(admin, admin.user) end end end - context '!staff?' do + context 'with !staff?' do it 'denies' do expect(subject).to_not permit(john, User) end @@ -31,21 +32,21 @@ RSpec.describe UserPolicy do end permissions :disable_2fa? do - context 'admin?' do - context '!record.staff?' do + context 'when admin?' do + context 'with !record.staff?' do it 'permits' do expect(subject).to permit(admin, john.user) end end - context 'record.staff?' do + context 'when record.staff?' do it 'denies' do expect(subject).to_not permit(admin, admin.user) end end end - context '!admin?' do + context 'with !admin?' do it 'denies' do expect(subject).to_not permit(john, User) end @@ -53,15 +54,15 @@ RSpec.describe UserPolicy do end permissions :confirm? do - context 'staff?' do - context '!record.confirmed?' do + context 'when staff?' do + context 'with !record.confirmed?' do it 'permits' do john.user.update(confirmed_at: nil) expect(subject).to permit(admin, john.user) end end - context 'record.confirmed?' do + context 'when record.confirmed?' do it 'denies' do john.user.confirm! expect(subject).to_not permit(admin, john.user) @@ -69,7 +70,7 @@ RSpec.describe UserPolicy do end end - context '!staff?' do + context 'with !staff?' do it 'denies' do expect(subject).to_not permit(john, User) end @@ -77,13 +78,13 @@ RSpec.describe UserPolicy do end permissions :enable? do - context 'staff?' do + context 'when staff?' do it 'permits' do expect(subject).to permit(admin, User) end end - context '!staff?' do + context 'with !staff?' do it 'denies' do expect(subject).to_not permit(john, User) end @@ -91,21 +92,21 @@ RSpec.describe UserPolicy do end permissions :disable? do - context 'staff?' do - context '!record.admin?' do + context 'when staff?' do + context 'with !record.admin?' do it 'permits' do expect(subject).to permit(admin, john.user) end end - context 'record.admin?' do + context 'when record.admin?' do it 'denies' do expect(subject).to_not permit(admin, admin.user) end end end - context '!staff?' do + context 'with !staff?' do it 'denies' do expect(subject).to_not permit(john, User) end diff --git a/spec/policies/webhook_policy_spec.rb b/spec/policies/webhook_policy_spec.rb new file mode 100644 index 00000000000..909311461a8 --- /dev/null +++ b/spec/policies/webhook_policy_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +describe WebhookPolicy do + let(:policy) { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:account) } + + permissions :index?, :create? do + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, Webhook) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, Webhook) + end + end + end + + permissions :show?, :update?, :enable?, :disable?, :rotate_secret?, :destroy? do + let(:webhook) { Fabricate(:webhook, events: ['account.created', 'report.created']) } + + context 'with an admin' do + it 'permits' do + expect(policy).to permit(admin, webhook) + end + end + + context 'with a non-admin' do + it 'denies' do + expect(policy).to_not permit(john, webhook) + end + end + end +end diff --git a/spec/presenters/account_relationships_presenter_spec.rb b/spec/presenters/account_relationships_presenter_spec.rb index 8a485d2b9a9..5c2ba54e000 100644 --- a/spec/presenters/account_relationships_presenter_spec.rb +++ b/spec/presenters/account_relationships_presenter_spec.rb @@ -14,12 +14,12 @@ RSpec.describe AccountRelationshipsPresenter do allow(Account).to receive(:domain_blocking_map).with(account_ids, current_account_id).and_return(default_map) end - let(:presenter) { AccountRelationshipsPresenter.new(account_ids, current_account_id, **options) } + let(:presenter) { described_class.new(account_ids, current_account_id, **options) } let(:current_account_id) { Fabricate(:account).id } let(:account_ids) { [Fabricate(:account).id] } let(:default_map) { { 1 => true } } - context 'options are not set' do + context 'when options are not set' do let(:options) { {} } it 'sets default maps' do @@ -32,7 +32,7 @@ RSpec.describe AccountRelationshipsPresenter do end end - context 'options[:following_map] is set' do + context 'when options[:following_map] is set' do let(:options) { { following_map: { 2 => true } } } it 'sets @following merged with default_map and options[:following_map]' do @@ -40,7 +40,7 @@ RSpec.describe AccountRelationshipsPresenter do end end - context 'options[:followed_by_map] is set' do + context 'when options[:followed_by_map] is set' do let(:options) { { followed_by_map: { 3 => true } } } it 'sets @followed_by merged with default_map and options[:followed_by_map]' do @@ -48,7 +48,7 @@ RSpec.describe AccountRelationshipsPresenter do end end - context 'options[:blocking_map] is set' do + context 'when options[:blocking_map] is set' do let(:options) { { blocking_map: { 4 => true } } } it 'sets @blocking merged with default_map and options[:blocking_map]' do @@ -56,7 +56,7 @@ RSpec.describe AccountRelationshipsPresenter do end end - context 'options[:muting_map] is set' do + context 'when options[:muting_map] is set' do let(:options) { { muting_map: { 5 => true } } } it 'sets @muting merged with default_map and options[:muting_map]' do @@ -64,7 +64,7 @@ RSpec.describe AccountRelationshipsPresenter do end end - context 'options[:requested_map] is set' do + context 'when options[:requested_map] is set' do let(:options) { { requested_map: { 6 => true } } } it 'sets @requested merged with default_map and options[:requested_map]' do @@ -72,7 +72,7 @@ RSpec.describe AccountRelationshipsPresenter do end end - context 'options[:requested_by_map] is set' do + context 'when options[:requested_by_map] is set' do let(:options) { { requested_by_map: { 6 => true } } } it 'sets @requested merged with default_map and options[:requested_by_map]' do @@ -80,7 +80,7 @@ RSpec.describe AccountRelationshipsPresenter do end end - context 'options[:domain_blocking_map] is set' do + context 'when options[:domain_blocking_map] is set' do let(:options) { { domain_blocking_map: { 7 => true } } } it 'sets @domain_blocking merged with default_map and options[:domain_blocking_map]' do diff --git a/spec/presenters/familiar_followers_presenter_spec.rb b/spec/presenters/familiar_followers_presenter_spec.rb index 607e3002f87..c21ffd36ecd 100644 --- a/spec/presenters/familiar_followers_presenter_spec.rb +++ b/spec/presenters/familiar_followers_presenter_spec.rb @@ -24,7 +24,7 @@ RSpec.describe FamiliarFollowersPresenter do expect(result).to_not be_nil expect(result.id).to eq requested_accounts.first.id - expect(result.accounts).to match_array([familiar_follower]) + expect(result.accounts).to contain_exactly(familiar_follower) end context 'when requested account hides followers' do diff --git a/spec/presenters/instance_presenter_spec.rb b/spec/presenters/instance_presenter_spec.rb index a451b5cba40..2a1d668ceb6 100644 --- a/spec/presenters/instance_presenter_spec.rb +++ b/spec/presenters/instance_presenter_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' describe InstancePresenter do - let(:instance_presenter) { InstancePresenter.new } + let(:instance_presenter) { described_class.new } describe '#description' do around do |example| @@ -87,8 +89,28 @@ describe InstancePresenter do end describe '#source_url' do - it 'returns "https://github.com/mastodon/mastodon"' do - expect(instance_presenter.source_url).to eq('https://github.com/mastodon/mastodon') + context 'with the GITHUB_REPOSITORY env variable set' do + around do |example| + ClimateControl.modify GITHUB_REPOSITORY: 'other/repo' do + example.run + end + end + + it 'uses the env variable to build a repo URL' do + expect(instance_presenter.source_url).to eq('https://github.com/other/repo') + end + end + + context 'without the GITHUB_REPOSITORY env variable set' do + around do |example| + ClimateControl.modify GITHUB_REPOSITORY: nil do + example.run + end + end + + it 'defaults to the core mastodon repo URL' do + expect(instance_presenter.source_url).to eq('https://github.com/mastodon/mastodon') + end end end diff --git a/spec/presenters/status_relationships_presenter_spec.rb b/spec/presenters/status_relationships_presenter_spec.rb index eaab922fd97..7746c8cd78c 100644 --- a/spec/presenters/status_relationships_presenter_spec.rb +++ b/spec/presenters/status_relationships_presenter_spec.rb @@ -12,13 +12,13 @@ RSpec.describe StatusRelationshipsPresenter do allow(Status).to receive(:pins_map).with(anything, current_account_id).and_return(default_map) end - let(:presenter) { StatusRelationshipsPresenter.new(statuses, current_account_id, **options) } + let(:presenter) { described_class.new(statuses, current_account_id, **options) } let(:current_account_id) { Fabricate(:account).id } let(:statuses) { [Fabricate(:status)] } - let(:status_ids) { statuses.map(&:id) + statuses.map(&:reblog_of_id).compact } + let(:status_ids) { statuses.map(&:id) + statuses.filter_map(&:reblog_of_id) } let(:default_map) { { 1 => true } } - context 'options are not set' do + context 'when options are not set' do let(:options) { {} } it 'sets default maps' do @@ -30,7 +30,7 @@ RSpec.describe StatusRelationshipsPresenter do end end - context 'options[:reblogs_map] is set' do + context 'when options[:reblogs_map] is set' do let(:options) { { reblogs_map: { 2 => true } } } it 'sets @reblogs_map merged with default_map and options[:reblogs_map]' do @@ -38,7 +38,7 @@ RSpec.describe StatusRelationshipsPresenter do end end - context 'options[:favourites_map] is set' do + context 'when options[:favourites_map] is set' do let(:options) { { favourites_map: { 3 => true } } } it 'sets @favourites_map merged with default_map and options[:favourites_map]' do @@ -46,7 +46,7 @@ RSpec.describe StatusRelationshipsPresenter do end end - context 'options[:bookmarks_map] is set' do + context 'when options[:bookmarks_map] is set' do let(:options) { { bookmarks_map: { 4 => true } } } it 'sets @bookmarks_map merged with default_map and options[:bookmarks_map]' do @@ -54,7 +54,7 @@ RSpec.describe StatusRelationshipsPresenter do end end - context 'options[:mutes_map] is set' do + context 'when options[:mutes_map] is set' do let(:options) { { mutes_map: { 5 => true } } } it 'sets @mutes_map merged with default_map and options[:mutes_map]' do @@ -62,7 +62,7 @@ RSpec.describe StatusRelationshipsPresenter do end end - context 'options[:pins_map] is set' do + context 'when options[:pins_map] is set' do let(:options) { { pins_map: { 6 => true } } } it 'sets @pins_map merged with default_map and options[:pins_map]' do diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 9a14fc3b1d9..8d9677f6ced 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,4 +1,20 @@ +# 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) +RUN_SEARCH_SPECS = ENV.fetch('RUN_SEARCH_SPECS', false) + +if RUN_SYSTEM_SPECS + STREAMING_PORT = ENV.fetch('TEST_STREAMING_PORT', '4020') + ENV['STREAMING_API_BASE_URL'] = "http://localhost:#{STREAMING_PORT}" +end + +if RUN_SEARCH_SPECS + # Include any configuration or setups specific to search tests here +end + require File.expand_path('../config/environment', __dir__) abort('The Rails environment is running in production mode!') if Rails.env.production? @@ -8,14 +24,20 @@ require 'rspec/rails' require 'webmock/rspec' require 'paperclip/matchers' require 'capybara/rspec' +require 'chewy/rspec' -Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } +Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f } ActiveRecord::Migration.maintain_test_schema! -WebMock.disable_net_connect!(allow: Chewy.settings[:host]) +WebMock.disable_net_connect!(allow: Chewy.settings[:host], allow_localhost: RUN_SYSTEM_SPECS) Sidekiq::Testing.inline! Sidekiq.logger = nil +# System tests config +DatabaseCleaner.strategy = [:deletion] +streaming_server_manager = StreamingServerManager.new +search_data_manager = SearchDataManager.new + Devise::Test::ControllerHelpers.module_eval do alias_method :original_sign_in, :sign_in @@ -33,35 +55,111 @@ Devise::Test::ControllerHelpers.module_eval do end RSpec.configure do |config| - config.fixture_path = "#{Rails.root}/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 + when :search + !RUN_SEARCH_SPECS + end + } + config.fixture_path = 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! + config.define_derived_metadata(file_path: Regexp.new('spec/lib/mastodon/cli')) do |metadata| + metadata[:type] = :cli + end + config.include Devise::Test::ControllerHelpers, type: :controller + config.include Devise::Test::ControllerHelpers, type: :helper config.include Devise::Test::ControllerHelpers, type: :view + config.include Devise::Test::IntegrationHelpers, type: :feature + config.include Devise::Test::IntegrationHelpers, type: :request config.include Paperclip::Shoulda::Matchers config.include ActiveSupport::Testing::TimeHelpers + config.include Chewy::Rspec::Helpers config.include Redisable + config.include SignedRequestHelpers, type: :request + + config.around(:each, use_transactional_tests: false) do |example| + self.use_transactional_tests = false + example.run + self.use_transactional_tests = true + 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 :suite do + if RUN_SYSTEM_SPECS + Webpacker.compile + streaming_server_manager.start(port: STREAMING_PORT) + end + + if RUN_SEARCH_SPECS + Chewy.strategy(:urgent) + search_data_manager.prepare_test_data + end end - config.before :each, type: :service do - stub_jsonld_contexts! + config.after :suite do + streaming_server_manager.stop + + search_data_manager.cleanup_test_data if RUN_SEARCH_SPECS + end + + config.around :each, type: :system do |example| + # driven_by :selenium, using: :chrome, screen_size: [1600, 1200] + driven_by :selenium, using: :headless_chrome, screen_size: [1600, 1200] + + # The streaming server needs access to the database + # but with use_transactional_tests every transaction + # is rolled-back, so the streaming server never sees the data + # So we disable this feature for system tests, and use DatabaseCleaner to clean + # the database tables between each test + self.use_transactional_tests = false + + DatabaseCleaner.cleaning do + example.run + end + + self.use_transactional_tests = true + end + + config.around :each, type: :search do |example| + search_data_manager.populate_indexes + example.run + search_data_manager.remove_indexes + end + + config.before(:each) 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 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| @@ -71,15 +169,24 @@ end RSpec::Matchers.define_negated_matcher :not_change, :change def request_fixture(name) - File.read(Rails.root.join('spec', 'fixtures', 'requests', name)) + Rails.root.join('spec', 'fixtures', 'requests', name).read end def attachment_fixture(name) - File.open(Rails.root.join('spec', 'fixtures', 'files', name)) + Rails.root.join('spec', 'fixtures', 'files', name).open 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')) +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 diff --git a/spec/requests/anonymous_cookies_spec.rb b/spec/requests/anonymous_cookies_spec.rb new file mode 100644 index 00000000000..427f54e449c --- /dev/null +++ b/spec/requests/anonymous_cookies_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +context 'when visited anonymously' 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 + + describe 'account pages' do + it 'do not set cookies' do + alice = Fabricate(:account, username: 'alice', display_name: 'Alice') + _status = Fabricate(:status, account: alice, text: 'Hello World') + + get '/@alice' + + expect(response.cookies).to be_empty + end + end + + describe 'status pages' do + it 'do not set cookies' do + alice = Fabricate(:account, username: 'alice', display_name: 'Alice') + status = Fabricate(:status, account: alice, text: 'Hello World') + + get short_account_status_url(alice, status) + + expect(response.cookies).to be_empty + end + end + + describe 'the /about page' do + it 'does not set cookies' do + get '/about' + + expect(response.cookies).to be_empty + end + 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 00000000000..b13e79b12b9 --- /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_show_spec.rb b/spec/requests/api/v1/accounts_show_spec.rb new file mode 100644 index 00000000000..ee6e925aa96 --- /dev/null +++ b/spec/requests/api/v1/accounts_show_spec.rb @@ -0,0 +1,53 @@ +# 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/admin/account_actions_spec.rb b/spec/requests/api/v1/admin/account_actions_spec.rb new file mode 100644 index 00000000000..bdf1f08e43b --- /dev/null +++ b/spec/requests/api/v1/admin/account_actions_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Account actions' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + 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 + end + end + + shared_examples 'a successful logged action' do |action_type, target_type| + it 'logs action' do + subject + + log_item = Admin::ActionLog.last + + 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) + end + end + + describe 'POST /api/v1/admin/accounts/:id/action' do + subject do + post "/api/v1/admin/accounts/#{target_account.id}/action", headers: headers, params: params + end + + let(:target_account) { Fabricate(:account) } + + context 'with type of disable' do + let(:params) { { type: 'disable' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'a successful notification delivery' + it_behaves_like 'a successful logged action', :disable, :user + + 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 + + context 'with type of sensitive' do + let(:params) { { type: 'sensitive' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'a successful notification delivery' + it_behaves_like 'a successful logged action', :sensitive, :account + + 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 + + context 'with type of silence' do + let(:params) { { type: 'silence' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'a successful notification delivery' + it_behaves_like 'a successful logged action', :silence, :account + + 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 + + context 'with type of suspend' do + let(:params) { { type: 'suspend' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'a successful notification delivery' + it_behaves_like 'a successful logged action', :suspend, :account + + 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 + + context 'with type of none' do + let(:params) { { type: 'none' } } + + it_behaves_like 'a successful notification delivery' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + + context 'with no type' do + let(:params) { {} } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'with invalid type' do + let(:params) { { type: 'invalid' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + 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 00000000000..8e158f623d6 --- /dev/null +++ b/spec/requests/api/v1/admin/accounts_spec.rb @@ -0,0 +1,401 @@ +# 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 + + log_item = Admin::ActionLog.last + + expect(log_item).to be_present + expect(log_item.action).to eq :approve + expect(log_item.account_id).to eq user.account_id + expect(log_item.target_id).to 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 + + log_item = Admin::ActionLog.last + + expect(log_item).to be_present + expect(log_item.action).to eq :reject + expect(log_item.account_id).to eq user.account_id + expect(log_item.target_id).to 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 +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 new file mode 100644 index 00000000000..3f33b50f39a --- /dev/null +++ b/spec/requests/api/v1/admin/canonical_email_blocks_spec.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Canonical Email Blocks' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'admin:read:canonical_email_blocks admin:write:canonical_email_blocks' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/admin/canonical_email_blocks' do + subject do + get '/api/v1/admin/canonical_email_blocks', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + 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 + + context 'when there is no canonical email block' do + it 'returns an empty list' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are canonical email blocks' do + let!(:canonical_email_blocks) { Fabricate.times(5, :canonical_email_block) } + let(:expected_email_hashes) { canonical_email_blocks.pluck(:canonical_email_hash) } + + it 'returns the correct canonical email hashes' do + subject + + expect(body_as_json.pluck(:canonical_email_hash)).to match_array(expected_email_hashes) + end + + context 'with limit param' do + let(:params) { { limit: 2 } } + + it 'returns only the requested number of canonical email blocks' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + + context 'with since_id param' do + let(:params) { { since_id: canonical_email_blocks[1].id } } + + it 'returns only the canonical email blocks after since_id' do + subject + + canonical_email_blocks_ids = canonical_email_blocks.pluck(:id).map(&:to_s) + + expect(body_as_json.pluck(:id)).to match_array(canonical_email_blocks_ids[2..]) + end + end + + context 'with max_id param' do + let(:params) { { max_id: canonical_email_blocks[3].id } } + + it 'returns only the canonical email blocks before max_id' do + subject + + canonical_email_blocks_ids = canonical_email_blocks.pluck(:id).map(&:to_s) + + expect(body_as_json.pluck(:id)).to match_array(canonical_email_blocks_ids[..2]) + end + end + end + end + + describe 'GET /api/v1/admin/canonical_email_blocks/:id' do + subject do + get "/api/v1/admin/canonical_email_blocks/#{canonical_email_block.id}", headers: headers + end + + let!(:canonical_email_block) { Fabricate(:canonical_email_block) } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + context 'when the requested canonical email block exists' do + it 'returns the requested canonical email block data correctly', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + json = body_as_json + + expect(json[:id]).to eq(canonical_email_block.id.to_s) + expect(json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) + end + end + + context 'when the requested canonical block does not exist' do + it 'returns http not found' do + get '/api/v1/admin/canonical_email_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/canonical_email_blocks/test' do + subject do + post '/api/v1/admin/canonical_email_blocks/test', headers: headers, params: params + end + + let(:params) { { email: 'email@example.com' } } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + context 'when the required email param is not provided' do + let(:params) { {} } + + it 'returns http bad request' do + subject + + expect(response).to have_http_status(400) + end + end + + context 'when the required email param is provided' do + context 'when there is a matching canonical email block' do + let!(:canonical_email_block) { CanonicalEmailBlock.create(params) } + + it 'returns the expected canonical email hash', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + 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 an empty list', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to be_empty + end + end + end + end + + describe 'POST /api/v1/admin/canonical_email_blocks' do + subject do + post '/api/v1/admin/canonical_email_blocks', headers: headers, params: params + end + + let(:params) { { email: 'example@email.com' } } + let(:canonical_email_block) { CanonicalEmailBlock.new(email: params[:email]) } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns the canonical_email_hash correctly', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) + end + + context 'when the required email param is not provided' do + let(:params) { {} } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the canonical_email_hash param is provided instead of email' do + let(:params) { { canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } } + + it 'returns the correct canonical_email_hash', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:canonical_email_hash]).to eq(params[:canonical_email_hash]) + end + end + + context 'when both email and canonical_email_hash params are provided' do + let(:params) { { email: 'example@email.com', canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } } + + it 'ignores the canonical_email_hash param', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) + end + end + + context 'when the given canonical email was already blocked' do + before do + canonical_email_block.save + end + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'DELETE /api/v1/admin/canonical_email_blocks/:id' do + subject do + delete "/api/v1/admin/canonical_email_blocks/#{canonical_email_block.id}", headers: headers + end + + let!(:canonical_email_block) { Fabricate(:canonical_email_block) } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'deletes the canonical email block', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(CanonicalEmailBlock.find_by(id: canonical_email_block.id)).to be_nil + end + + context 'when the canonical email block is not found' do + it 'returns http not found' do + delete '/api/v1/admin/canonical_email_blocks/0', headers: headers + + expect(response).to have_http_status(404) + 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 new file mode 100644 index 00000000000..6db1ab6e307 --- /dev/null +++ b/spec/requests/api/v1/admin/domain_allows_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Domain Allows' 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/domain_allows' do + subject do + get '/api/v1/admin/domain_allows', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + 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 + + context 'when there is no allowed domains' do + it 'returns an empty body' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are allowed domains' do + let!(:domain_allows) { Fabricate.times(5, :domain_allow) } + let(:expected_response) do + domain_allows.map do |domain_allow| + { + id: domain_allow.id.to_s, + domain: domain_allow.domain, + created_at: domain_allow.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + } + end + end + + it 'returns the correct allowed domains' do + subject + + 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 allowed domains' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + end + end + + describe 'GET /api/v1/admin/domain_allows/:id' do + subject do + get "/api/v1/admin/domain_allows/#{domain_allow.id}", headers: headers + end + + let!(:domain_allow) { Fabricate(:domain_allow) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns the expected allowed domain name', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:domain]).to eq domain_allow.domain + end + + context 'when the requested allowed domain does not exist' do + it 'returns http not found' do + get '/api/v1/admin/domain_allows/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/domain_allows' do + subject do + post '/api/v1/admin/domain_allows', headers: headers, params: params + end + + let(:params) { { domain: 'foo.bar.com' } } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + context 'with a valid domain name' do + it 'returns the expected domain name', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:domain]).to eq 'foo.bar.com' + expect(DomainAllow.find_by(domain: 'foo.bar.com')).to be_present + end + end + + context 'with invalid domain name' do + let(:params) { 'foo bar' } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when domain name is not specified' do + let(:params) { {} } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the domain is already allowed' do + before do + DomainAllow.create(params) + end + + it 'returns the existing allowed domain name' do + subject + + expect(body_as_json[:domain]).to eq(params[:domain]) + end + end + end + + describe 'DELETE /api/v1/admin/domain_allows/:id' do + subject do + delete "/api/v1/admin/domain_allows/#{domain_allow.id}", headers: headers + end + + let!(:domain_allow) { Fabricate(:domain_allow) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'deletes the allowed domain', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(DomainAllow.find_by(id: domain_allow.id)).to be_nil + end + + context 'when the allowed domain does not exist' do + it 'returns http not found' do + delete '/api/v1/admin/domain_allows/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/admin/domain_blocks_spec.rb b/spec/requests/api/v1/admin/domain_blocks_spec.rb new file mode 100644 index 00000000000..1fb6fc8228b --- /dev/null +++ b/spec/requests/api/v1/admin/domain_blocks_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Domain Blocks' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:read:domain_blocks admin:write:domain_blocks' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/admin/domain_blocks' do + subject do + get '/api/v1/admin/domain_blocks', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + 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 + + context 'when there are no domain blocks' do + it 'returns an empty list' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are domain blocks' do + let!(:domain_blocks) do + [ + Fabricate(:domain_block, severity: :silence, reject_media: true), + Fabricate(:domain_block, severity: :suspend, obfuscate: true), + Fabricate(:domain_block, severity: :noop, reject_reports: true), + Fabricate(:domain_block, public_comment: 'Spam'), + Fabricate(:domain_block, private_comment: 'Spam'), + ] + end + let(:expected_responde) do + domain_blocks.map do |domain_block| + { + id: domain_block.id.to_s, + domain: domain_block.domain, + created_at: domain_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + severity: domain_block.severity.to_s, + reject_media: domain_block.reject_media, + reject_reports: domain_block.reject_reports, + private_comment: domain_block.private_comment, + public_comment: domain_block.public_comment, + obfuscate: domain_block.obfuscate, + } + end + end + + it 'returns the expected domain blocks' do + subject + + expect(body_as_json).to match_array(expected_responde) + end + + context 'with limit param' do + let(:params) { { limit: 2 } } + + it 'returns only the requested number of domain blocks' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + end + end + + describe 'GET /api/v1/admin/domain_blocks/:id' do + subject do + get "/api/v1/admin/domain_blocks/#{domain_block.id}", headers: headers + end + + let!(:domain_block) { Fabricate(:domain_block) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns the expected domain block content', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to eq( + { + id: domain_block.id.to_s, + domain: domain_block.domain, + created_at: domain_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + severity: domain_block.severity.to_s, + reject_media: domain_block.reject_media, + reject_reports: domain_block.reject_reports, + private_comment: domain_block.private_comment, + public_comment: domain_block.public_comment, + obfuscate: domain_block.obfuscate, + } + ) + end + + context 'when the requested domain block does not exist' do + it 'returns http not found' do + get '/api/v1/admin/domain_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/domain_blocks' do + subject do + post '/api/v1/admin/domain_blocks', headers: headers, params: params + end + + let(:params) { { domain: 'foo.bar.com', severity: :silence } } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + 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', + } + ) + + expect(DomainBlock.find_by(domain: 'foo.bar.com')).to be_present + end + + context 'when a stricter domain block already exists' do + before do + Fabricate(:domain_block, domain: 'bar.com', severity: :suspend) + end + + it 'returns existing domain block in error', :aggregate_failures do + subject + + expect(response).to have_http_status(422) + expect(body_as_json[:existing_domain_block][:domain]).to eq('bar.com') + end + end + + context 'when given domain name is invalid' do + let(:params) { { domain: 'foo bar', severity: :silence } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'PUT /api/v1/admin/domain_blocks/:id' do + subject do + put "/api/v1/admin/domain_blocks/#{domain_block.id}", headers: headers, params: params + end + + let!(:domain_block) { Fabricate(:domain_block, domain: 'example.com', severity: :silence) } + let(:params) { { domain: 'example.com', severity: 'suspend' } } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns the updated domain block', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match a_hash_including( + { + id: domain_block.id.to_s, + domain: domain_block.domain, + severity: 'suspend', + } + ) + end + + it 'updates the block severity' do + expect { subject }.to change { domain_block.reload.severity }.from('silence').to('suspend') + end + + context 'when domain block does not exist' do + it 'returns http not found' do + put '/api/v1/admin/domain_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE /api/v1/admin/domain_blocks/:id' do + subject do + delete "/api/v1/admin/domain_blocks/#{domain_block.id}", headers: headers + end + + let!(:domain_block) { Fabricate(:domain_block) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'deletes the domain block', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(DomainBlock.find_by(id: domain_block.id)).to be_nil + end + + context 'when domain block does not exist' do + it 'returns http not found' do + delete '/api/v1/admin/domain_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end +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 new file mode 100644 index 00000000000..16656e0202c --- /dev/null +++ b/spec/requests/api/v1/admin/email_domain_blocks_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Email Domain Blocks' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:account) { Fabricate(:account) } + let(:scopes) { 'admin:read:email_domain_blocks admin:write:email_domain_blocks' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/admin/email_domain_blocks' do + subject do + get '/api/v1/admin/email_domain_blocks', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + 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 + + context 'when there is no email domain block' do + it 'returns an empty list' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are email domain blocks' do + let!(:email_domain_blocks) { Fabricate.times(5, :email_domain_block) } + let(:blocked_email_domains) { email_domain_blocks.pluck(:domain) } + + it 'return the correct blocked email domains' do + subject + + expect(body_as_json.pluck(:domain)).to match_array(blocked_email_domains) + end + + context 'with limit param' do + let(:params) { { limit: 2 } } + + it 'returns only the requested number of email domain blocks' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + + context 'with since_id param' do + let(:params) { { since_id: email_domain_blocks[1].id } } + + it 'returns only the email domain blocks after since_id' do + subject + + email_domain_blocks_ids = email_domain_blocks.pluck(:id).map(&:to_s) + + expect(body_as_json.pluck(:id)).to match_array(email_domain_blocks_ids[2..]) + end + end + + context 'with max_id param' do + let(:params) { { max_id: email_domain_blocks[3].id } } + + it 'returns only the email domain blocks before max_id' do + subject + + email_domain_blocks_ids = email_domain_blocks.pluck(:id).map(&:to_s) + + expect(body_as_json.pluck(:id)).to match_array(email_domain_blocks_ids[..2]) + end + end + end + end + + describe 'GET /api/v1/admin/email_domain_blocks/:id' do + subject do + get "/api/v1/admin/email_domain_blocks/#{email_domain_block.id}", headers: headers + end + + let!(:email_domain_block) { Fabricate(:email_domain_block) } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + context 'when email domain block exists' do + it 'returns the correct blocked domain', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:domain]).to eq(email_domain_block.domain) + end + end + + context 'when email domain block does not exist' do + it 'returns http not found' do + get '/api/v1/admin/email_domain_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/email_domain_blocks' do + subject do + post '/api/v1/admin/email_domain_blocks', headers: headers, params: params + end + + let(:params) { { domain: 'example.com' } } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns the correct blocked email domain', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:domain]).to eq(params[:domain]) + end + + context 'when domain param is not provided' do + let(:params) { { domain: '' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when provided domain name has an invalid character' do + let(:params) { { domain: 'do\uD800.com' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when provided domain is already blocked' do + before do + EmailDomainBlock.create(params) + end + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'DELETE /api/v1/admin/email_domain_blocks' do + subject do + delete "/api/v1/admin/email_domain_blocks/#{email_domain_block.id}", headers: headers + end + + let!(:email_domain_block) { Fabricate(:email_domain_block) } + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'deletes email domain block', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to be_empty + expect(EmailDomainBlock.find_by(id: email_domain_block.id)).to be_nil + end + + context 'when email domain block does not exist' do + it 'returns http not found' do + delete '/api/v1/admin/email_domain_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/admin/ip_blocks_spec.rb b/spec/requests/api/v1/admin/ip_blocks_spec.rb new file mode 100644 index 00000000000..fbcb39e3bef --- /dev/null +++ b/spec/requests/api/v1/admin/ip_blocks_spec.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'IP Blocks' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'admin:read:ip_blocks admin:write:ip_blocks' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/admin/ip_blocks' do + subject do + get '/api/v1/admin/ip_blocks', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks' + 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 + + context 'when there is no ip block' do + it 'returns an empty body' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are ip blocks' do + let!(:ip_blocks) do + [ + IpBlock.create(ip: '192.0.2.0/24', severity: :no_access), + IpBlock.create(ip: '172.16.0.1', severity: :sign_up_requires_approval, comment: 'Spam'), + IpBlock.create(ip: '2001:0db8::/32', severity: :sign_up_block, expires_in: 10.days), + ] + end + let(:expected_response) do + ip_blocks.map do |ip_block| + { + id: ip_block.id.to_s, + ip: ip_block.ip, + severity: ip_block.severity.to_s, + comment: ip_block.comment, + created_at: ip_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + expires_at: ip_block.expires_at&.strftime('%Y-%m-%dT%H:%M:%S.%LZ'), + } + end + end + + it 'returns the correct blocked ips' do + subject + + 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 ip blocks' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + end + end + + describe 'GET /api/v1/admin/ip_blocks/:id' do + subject do + get "/api/v1/admin/ip_blocks/#{ip_block.id}", headers: headers + end + + let!(:ip_block) { IpBlock.create(ip: '192.0.2.0/24', severity: :no_access) } + + it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns the correct ip block', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + json = body_as_json + + expect(json[:ip]).to eq("#{ip_block.ip}/#{ip_block.ip.prefix}") + expect(json[:severity]).to eq(ip_block.severity.to_s) + end + + context 'when ip block does not exist' do + it 'returns http not found' do + get '/api/v1/admin/ip_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/ip_blocks' do + subject do + post '/api/v1/admin/ip_blocks', headers: headers, params: params + end + + let(:params) { { ip: '151.0.32.55', severity: 'no_access', comment: 'Spam' } } + + it_behaves_like 'forbidden for wrong scope', 'admin:read:ip_blocks' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' + + it 'returns the correct ip block', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + json = body_as_json + + expect(json[:ip]).to eq("#{params[:ip]}/32") + expect(json[:severity]).to eq(params[:severity]) + expect(json[:comment]).to eq(params[:comment]) + end + + context 'when the required ip param is not provided' do + let(:params) { { ip: '', severity: 'no_access' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the required severity param is not provided' do + let(:params) { { ip: '173.65.23.1', severity: '' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the given ip address is already blocked' do + before do + IpBlock.create(params) + end + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the given ip address is invalid' do + let(:params) { { ip: '520.13.54.120', severity: 'no_access' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'PUT /api/v1/admin/ip_blocks/:id' do + subject do + put "/api/v1/admin/ip_blocks/#{ip_block.id}", headers: headers, params: params + end + + 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 the correct ip block', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match(hash_including({ + ip: "#{ip_block.ip}/#{ip_block.ip.prefix}", + severity: 'sign_up_requires_approval', + comment: 'Decreasing severity', + })) + end + + it 'updates the severity correctly' do + expect { subject }.to 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') + end + + context 'when ip block does not exist' do + it 'returns http not found' do + put '/api/v1/admin/ip_blocks/-1', headers: headers, params: params + + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE /api/v1/admin/ip_blocks/:id' do + subject do + delete "/api/v1/admin/ip_blocks/#{ip_block.id}", headers: headers + end + + let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access') } + + it 'deletes the ip block', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to be_empty + expect(IpBlock.find_by(id: ip_block.id)).to be_nil + end + + context 'when ip block does not exist' do + it 'returns http not found' do + delete '/api/v1/admin/ip_blocks/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/admin/reports_spec.rb b/spec/requests/api/v1/admin/reports_spec.rb new file mode 100644 index 00000000000..5403457db02 --- /dev/null +++ b/spec/requests/api/v1/admin/reports_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Reports' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:read:reports admin:write:reports' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/admin/reports' do + subject do + get '/api/v1/admin/reports', 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 reports' do + it 'returns an empty list' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are reports' do + let!(:reporter) { Fabricate(:account) } + let!(:spammer) { Fabricate(:account) } + let(:expected_response) do + scope.map do |report| + hash_including({ + id: report.id.to_s, + action_taken: report.action_taken?, + category: report.category, + comment: report.comment, + account: hash_including(id: report.account.id.to_s), + target_account: hash_including(id: report.target_account.id.to_s), + statuses: report.statuses, + rules: report.rules, + forwarded: report.forwarded, + }) + end + end + let(:scope) { Report.unresolved } + + before do + Fabricate(:report) + Fabricate(:report, target_account: spammer) + Fabricate(:report, account: reporter, target_account: spammer) + Fabricate(:report, action_taken_at: 4.days.ago, account: reporter) + Fabricate(:report, action_taken_at: 20.days.ago) + end + + it 'returns all unresolved reports' do + subject + + expect(body_as_json).to match_array(expected_response) + end + + context 'with resolved param' do + let(:params) { { resolved: true } } + let(:scope) { Report.resolved } + + it 'returns only the resolved reports' do + subject + + expect(body_as_json).to match_array(expected_response) + end + end + + context 'with account_id param' do + let(:params) { { account_id: reporter.id } } + let(:scope) { Report.unresolved.where(account: reporter) } + + it 'returns all unresolved reports filed by the specified account' do + subject + + expect(body_as_json).to match_array(expected_response) + end + end + + context 'with target_account_id param' do + let(:params) { { target_account_id: spammer.id } } + let(:scope) { Report.unresolved.where(target_account: spammer) } + + it 'returns all unresolved reports targeting the specified account' do + subject + + expect(body_as_json).to match_array(expected_response) + end + end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'returns only the requested number of reports' do + subject + + expect(body_as_json.size).to eq(1) + end + end + end + end + + describe 'GET /api/v1/admin/reports/:id' do + subject do + get "/api/v1/admin/reports/#{report.id}", headers: headers + end + + let(:report) { Fabricate(:report) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns the requested report content', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to include( + { + id: report.id.to_s, + action_taken: report.action_taken?, + category: report.category, + comment: report.comment, + account: a_hash_including(id: report.account.id.to_s), + target_account: a_hash_including(id: report.target_account.id.to_s), + statuses: report.statuses, + rules: report.rules, + forwarded: report.forwarded, + } + ) + end + end + + describe 'PUT /api/v1/admin/reports/:id' do + subject do + put "/api/v1/admin/reports/#{report.id}", headers: headers, params: params + end + + let!(:report) { Fabricate(:report, category: :other) } + let(:params) { { category: 'spam' } } + + 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) + + report.reload + + expect(body_as_json).to include( + { + id: report.id.to_s, + action_taken: report.action_taken?, + category: report.category, + comment: report.comment, + account: a_hash_including(id: report.account.id.to_s), + target_account: a_hash_including(id: report.target_account.id.to_s), + statuses: report.statuses, + rules: report.rules, + forwarded: report.forwarded, + } + ) + end + end + + describe 'POST #resolve' do + subject do + post "/api/v1/admin/reports/#{report.id}/resolve", headers: headers + end + + let(:report) { Fabricate(:report, action_taken_at: nil) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + 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 + + describe 'POST #reopen' do + subject do + post "/api/v1/admin/reports/#{report.id}/reopen", headers: headers + end + + let(:report) { Fabricate(:report, action_taken_at: 10.days.ago) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + 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 + + describe 'POST #assign_to_self' do + subject do + post "/api/v1/admin/reports/#{report.id}/assign_to_self", headers: headers + end + + let(:report) { Fabricate(:report) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + 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 + + describe 'POST #unassign' do + subject do + post "/api/v1/admin/reports/#{report.id}/unassign", headers: headers + end + + let(:report) { Fabricate(:report, assigned_account_id: user.account.id) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + 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/tags_spec.rb b/spec/requests/api/v1/admin/tags_spec.rb new file mode 100644 index 00000000000..031be17f52a --- /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 00000000000..05020b0fd06 --- /dev/null +++ b/spec/requests/api/v1/admin/trends/links/links_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Links' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:read admin:write' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/admin/trends/links' do + subject do + get '/api/v1/admin/trends/links', headers: headers + end + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + + describe 'POST /api/v1/admin/trends/links/:id/approve' do + subject do + post "/api/v1/admin/trends/links/#{preview_card.id}/approve", headers: headers + end + + let(:preview_card) { Fabricate(:preview_card, trendable: false) } + + it_behaves_like 'forbidden for wrong scope', 'read write' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'sets the link as trendable' do + expect { subject }.to change { preview_card.reload.trendable }.from(false).to(true) + end + + it 'returns the link data' do + subject + + expect(body_as_json).to match( + a_hash_including( + url: preview_card.url, + title: preview_card.title, + description: preview_card.description, + type: 'link', + requires_review: false + ) + ) + end + + context 'when the link does not exist' do + it 'returns http not found' do + post '/api/v1/admin/trends/links/-1/approve', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + end + + describe 'POST /api/v1/admin/trends/links/:id/reject' do + subject do + post "/api/v1/admin/trends/links/#{preview_card.id}/reject", headers: headers + end + + let(:preview_card) { Fabricate(:preview_card, trendable: false) } + + it_behaves_like 'forbidden for wrong scope', 'read write' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'does not set the link as trendable' do + expect { subject }.to_not(change { preview_card.reload.trendable }) + end + + it 'returns the link data' do + subject + + expect(body_as_json).to match( + a_hash_including( + url: preview_card.url, + title: preview_card.title, + description: preview_card.description, + type: 'link', + requires_review: false + ) + ) + end + + context 'when the link does not exist' do + it 'returns http not found' do + post '/api/v1/admin/trends/links/-1/reject', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + end +end diff --git a/spec/requests/api/v1/apps/credentials_spec.rb b/spec/requests/api/v1/apps/credentials_spec.rb new file mode 100644 index 00000000000..e1455fe799a --- /dev/null +++ b/spec/requests/api/v1/apps/credentials_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Credentials' do + describe 'GET /api/v1/apps/verify_credentials' do + subject do + get '/api/v1/apps/verify_credentials', headers: headers + end + + context 'with an oauth token' do + 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 + subject + + expect(response).to have_http_status(200) + end + + it 'returns the app information correctly' do + subject + + 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 'without an oauth token' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + 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/controllers/api/v1/apps_controller_spec.rb b/spec/requests/api/v1/apps_spec.rb similarity index 58% rename from spec/controllers/api/v1/apps_controller_spec.rb rename to spec/requests/api/v1/apps_spec.rb index 9ac7880a4ab..acabbc93f0b 100644 --- a/spec/controllers/api/v1/apps_controller_spec.rb +++ b/spec/requests/api/v1/apps_spec.rb @@ -1,15 +1,19 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Api::V1::AppsController, type: :controller do - render_views +RSpec.describe 'Apps' do + describe 'POST /api/v1/apps' do + subject do + post '/api/v1/apps', params: params + end - describe 'POST #create' do - let(:client_name) { 'Test app' } - let(:scopes) { nil } + let(:client_name) { 'Test app' } + let(:scopes) { nil } let(:redirect_uris) { 'urn:ietf:wg:oauth:2.0:oob' } - let(:website) { nil } + let(:website) { nil } - let(:app_params) do + let(:params) do { client_name: client_name, redirect_uris: redirect_uris, @@ -18,24 +22,17 @@ RSpec.describe Api::V1::AppsController, type: :controller do } end - before do - post :create, params: app_params - 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 + expect(Doorkeeper::Application.find_by(name: client_name)).to be_present - it 'creates an OAuth app' do - expect(Doorkeeper::Application.find_by(name: client_name)).to_not be_nil - end + body = body_as_json - it 'returns client ID and client secret' do - json = body_as_json - - expect(json[:client_id]).to_not be_blank - expect(json[:client_secret]).to_not be_blank + expect(body[:client_id]).to be_present + expect(body[:client_secret]).to be_present end end @@ -43,6 +40,8 @@ RSpec.describe Api::V1::AppsController, type: :controller do let(:scopes) { 'hoge' } it 'returns http unprocessable entity' do + subject + expect(response).to have_http_status(422) end end @@ -50,11 +49,10 @@ RSpec.describe Api::V1::AppsController, type: :controller do context 'with many duplicate scopes' do let(:scopes) { (%w(read) * 40).join(' ') } - it 'returns http success' do - expect(response).to have_http_status(200) - end + it 'only saves the scope once', :aggregate_failures do + subject - it 'only saves the scope once' do + expect(response).to have_http_status(200) expect(Doorkeeper::Application.find_by(name: client_name).scopes.to_s).to eq 'read' end end @@ -63,22 +61,39 @@ RSpec.describe Api::V1::AppsController, type: :controller do let(:client_name) { 'hoge' * 20 } it 'returns http unprocessable entity' do + subject + expect(response).to have_http_status(422) end end context 'with a too-long website' do - let(:website) { 'https://foo.bar/' + ('hoge' * 2_000) } + let(:website) { "https://foo.bar/#{'hoge' * 2_000}" } it 'returns http unprocessable entity' do + subject + expect(response).to have_http_status(422) end end context 'with a too-long redirect_uris' do - let(:redirect_uris) { 'https://foo.bar/' + ('hoge' * 2_000) } + let(:redirect_uris) { "https://foo.bar/#{'hoge' * 2_000}" } it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'without required params' do + let(:client_name) { '' } + let(:redirect_uris) { '' } + + it 'returns http unprocessable entity' do + subject + expect(response).to have_http_status(422) 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 00000000000..62543157c32 --- /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 00000000000..1f1cd35caac --- /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(3, :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: 2 } } + + 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/domain_blocks_spec.rb b/spec/requests/api/v1/domain_blocks_spec.rb new file mode 100644 index 00000000000..954497ebe15 --- /dev/null +++ b/spec/requests/api/v1/domain_blocks_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Domain blocks' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:blocks write:blocks' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/domain_blocks' do + subject do + get '/api/v1/domain_blocks', headers: headers, params: params + end + + let(:blocked_domains) { ['example.com', 'example.net', 'example.org', 'example.com.br'] } + let(:params) { {} } + + before do + blocked_domains.each { |domain| user.account.block_domain!(domain) } + end + + it_behaves_like 'forbidden for wrong scope', 'write:blocks' + + it 'returns the domains blocked by the requesting user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match_array(blocked_domains) + end + + context 'with limit param' do + let(:params) { { limit: 2 } } + + it 'returns only the requested number of blocked domains' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + end + + describe 'POST /api/v1/domain_blocks' do + subject do + post '/api/v1/domain_blocks', headers: headers, params: params + end + + let(:params) { { domain: 'example.com' } } + + it_behaves_like 'forbidden for wrong scope', 'read read:blocks' + + it 'creates a domain block', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.domain_blocking?(params[:domain])).to be(true) + end + + context 'when no domain name is given' do + let(:params) { { domain: '' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the given domain name is invalid' do + let(:params) { { domain: 'example com' } } + + it 'returns unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'DELETE /api/v1/domain_blocks' do + subject do + delete '/api/v1/domain_blocks/', headers: headers, params: params + end + + let(:params) { { domain: 'example.com' } } + + before do + user.account.block_domain!('example.com') + end + + it_behaves_like 'forbidden for wrong scope', 'read read:blocks' + + it 'deletes the specified domain block', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.domain_blocking?('example.com')).to be(false) + end + + context 'when the given domain name is not blocked' do + let(:params) { { domain: 'example.org' } } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + end +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 00000000000..8f5171ee782 --- /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/favourites_spec.rb b/spec/requests/api/v1/favourites_spec.rb new file mode 100644 index 00000000000..713990592c3 --- /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(3, :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: 2 } } + + 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_spec.rb b/spec/requests/api/v1/featured_tags_spec.rb new file mode 100644 index 00000000000..6c171f6e47a --- /dev/null +++ b/spec/requests/api/v1/featured_tags_spec.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'FeaturedTags' do + let(:user) { Fabricate(:user) } + 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/featured_tags' do + context 'with wrong scope' do + before do + get '/api/v1/featured_tags', headers: headers + end + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + end + + context 'when Authorization header is missing' do + it 'returns http unauthorized' do + get '/api/v1/featured_tags' + + expect(response).to have_http_status(401) + end + end + + it 'returns http success' do + get '/api/v1/featured_tags', headers: headers + + expect(response).to have_http_status(200) + end + + context 'when the requesting user has no featured tag' do + before { Fabricate.times(3, :featured_tag) } + + it 'returns an empty body' do + get '/api/v1/featured_tags', headers: headers + + body = body_as_json + + expect(body).to be_empty + end + end + + context 'when the requesting user has featured tags' do + let!(:user_featured_tags) { Fabricate.times(5, :featured_tag, account: user.account) } + + it 'returns only the featured tags belonging to the requesting user' do + get '/api/v1/featured_tags', headers: headers + + body = body_as_json + expected_ids = user_featured_tags.pluck(:id).map(&:to_s) + + expect(body.pluck(:id)).to match_array(expected_ids) + end + end + end + + describe 'POST /api/v1/featured_tags' do + let(:params) { { name: 'tag' } } + + it 'returns http success' do + post '/api/v1/featured_tags', headers: headers, params: params + + expect(response).to have_http_status(200) + end + + it 'returns the correct tag name' do + post '/api/v1/featured_tags', headers: headers, params: params + + body = body_as_json + + expect(body[:name]).to eq(params[:name]) + end + + it 'creates a new featured tag for the requesting user' do + post '/api/v1/featured_tags', headers: headers, params: params + + featured_tag = FeaturedTag.find_by(name: params[:name], account: user.account) + + expect(featured_tag).to be_present + end + + context 'with wrong scope' do + before do + post '/api/v1/featured_tags', headers: headers, params: params + end + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + end + + context 'when Authorization header is missing' do + it 'returns http unauthorized' do + post '/api/v1/featured_tags', params: params + + expect(response).to have_http_status(401) + end + end + + context 'when required param "name" is not provided' do + it 'returns http bad request' do + post '/api/v1/featured_tags', headers: headers + + expect(response).to have_http_status(400) + end + end + + context 'when provided tag name is invalid' do + let(:params) { { name: 'asj&*!' } } + + it 'returns http unprocessable entity' do + post '/api/v1/featured_tags', headers: headers, params: params + + expect(response).to have_http_status(422) + end + end + + context 'when tag name is already taken' do + before do + FeaturedTag.create(name: params[:name], account: user.account) + end + + it 'returns http unprocessable entity' do + post '/api/v1/featured_tags', headers: headers, params: params + + expect(response).to have_http_status(422) + end + end + end + + describe 'DELETE /api/v1/featured_tags' do + let!(:featured_tag) { FeaturedTag.create(name: 'tag', account: user.account) } + let(:id) { featured_tag.id } + + it 'returns http success' do + delete "/api/v1/featured_tags/#{id}", headers: headers + + expect(response).to have_http_status(200) + end + + it 'returns an empty body' do + delete "/api/v1/featured_tags/#{id}", headers: headers + + body = body_as_json + + expect(body).to be_empty + end + + it 'deletes the featured tag' do + delete "/api/v1/featured_tags/#{id}", headers: headers + + featured_tag = FeaturedTag.find_by(id: id) + + expect(featured_tag).to be_nil + end + + context 'with wrong scope' do + before do + delete "/api/v1/featured_tags/#{id}", headers: headers + end + + it_behaves_like 'forbidden for wrong scope', 'read:statuses' + end + + context 'when Authorization header is missing' do + it 'returns http unauthorized' do + delete "/api/v1/featured_tags/#{id}" + + expect(response).to have_http_status(401) + end + end + + context 'when featured tag with given id does not exist' do + it 'returns http not found' do + delete '/api/v1/featured_tags/0', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'when deleting a featured tag of another user' do + let!(:other_user_featured_tag) { Fabricate(:featured_tag) } + let(:id) { other_user_featured_tag.id } + + it 'returns http not found' do + delete "/api/v1/featured_tags/#{id}", headers: headers + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/follow_requests_spec.rb b/spec/requests/api/v1/follow_requests_spec.rb new file mode 100644 index 00000000000..1d78c9be19f --- /dev/null +++ b/spec/requests/api/v1/follow_requests_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Follow requests' do + let(:user) { Fabricate(:user, account_attributes: { locked: true }) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:follows write:follows' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/follow_requests' do + subject do + get '/api/v1/follow_requests', headers: headers, params: params + end + + let(:accounts) { Fabricate.times(5, :account) } + let(:params) { {} } + + 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| FollowService.new.call(account, user.account) } + end + + it_behaves_like 'forbidden for wrong scope', 'write write:follows' + + it 'returns the expected content from accounts requesting to follow', :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 follow requests' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + end + + describe 'POST /api/v1/follow_requests/:account_id/authorize' do + subject do + post "/api/v1/follow_requests/#{follower.id}/authorize", headers: headers + end + + let(:follower) { Fabricate(:account) } + + before do + FollowService.new.call(follower, user.account) + end + + it_behaves_like 'forbidden for wrong scope', 'read read:follows' + + it 'allows the requesting follower to follow', :aggregate_failures do + expect { subject }.to change { follower.following?(user.account) }.from(false).to(true) + expect(response).to have_http_status(200) + expect(body_as_json[:followed_by]).to be true + end + end + + describe 'POST /api/v1/follow_requests/:account_id/reject' do + subject do + post "/api/v1/follow_requests/#{follower.id}/reject", headers: headers + end + + let(:follower) { Fabricate(:account) } + + before do + FollowService.new.call(follower, user.account) + end + + it_behaves_like 'forbidden for wrong scope', 'read read:follows' + + it 'removes the follow request', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(FollowRequest.where(target_account: user.account, account: follower)).to_not exist + expect(body_as_json[:followed_by]).to be false + end + 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 00000000000..9391c7bdc8b --- /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(5, :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: 3 } } + + 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[2].id)) + end + 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 00000000000..8ab8bf99ce5 --- /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/lists/accounts_spec.rb b/spec/requests/api/v1/lists/accounts_spec.rb new file mode 100644 index 00000000000..4d2a168b34b --- /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(3, :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 new file mode 100644 index 00000000000..22dde43a190 --- /dev/null +++ b/spec/requests/api/v1/lists_spec.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Lists' 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' do + subject do + get '/api/v1/lists', headers: headers + end + + let!(:lists) do + [ + Fabricate(:list, account: user.account, title: 'first list', replies_policy: :followed), + Fabricate(:list, account: user.account, title: 'second list', replies_policy: :list), + Fabricate(:list, account: user.account, title: 'third list', replies_policy: :none), + Fabricate(:list, account: user.account, title: 'fourth list', exclusive: true), + ] + end + + let(:expected_response) do + lists.map do |list| + { + id: list.id.to_s, + title: list.title, + replies_policy: list.replies_policy, + exclusive: list.exclusive, + } + end + end + + before do + Fabricate(:list) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:lists' + + it 'returns the expected lists', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match_array(expected_response) + end + end + + describe 'GET /api/v1/lists/:id' do + subject do + get "/api/v1/lists/#{list.id}", headers: headers + end + + let(:list) { Fabricate(:list, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'write write:lists' + + it 'returns the requested list correctly', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to eq({ + id: list.id.to_s, + title: list.title, + replies_policy: list.replies_policy, + exclusive: list.exclusive, + }) + end + + context 'when the list belongs to a different user' do + let(:list) { Fabricate(:list) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when the list does not exist' do + it 'returns http not found' do + get '/api/v1/lists/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/lists' do + subject do + post '/api/v1/lists', headers: headers, params: params + end + + let(:params) { { title: 'my list', replies_policy: 'none', exclusive: 'true' } } + + it_behaves_like 'forbidden for wrong scope', 'read read:lists' + + it 'returns the new list', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match(a_hash_including(title: 'my list', replies_policy: 'none', exclusive: true)) + expect(List.where(account: user.account).count).to eq(1) + end + + context 'when a title is not given' do + let(:params) { { title: '' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the given replies_policy is invalid' do + let(:params) { { title: 'a list', replies_policy: 'whatever' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'PUT /api/v1/lists/:id' do + subject do + put "/api/v1/lists/#{list.id}", headers: headers, params: params + end + + let(:list) { Fabricate(:list, account: user.account, title: 'my list') } + let(:params) { { title: 'list', replies_policy: 'followed', exclusive: 'true' } } + + it_behaves_like 'forbidden for wrong scope', 'read read:lists' + + it 'returns the updated list', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + list.reload + + expect(body_as_json).to eq({ + id: list.id.to_s, + title: list.title, + replies_policy: list.replies_policy, + exclusive: list.exclusive, + }) + end + + it 'updates the list title' do + expect { subject }.to 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') + end + + it 'updates the list exclusive' do + expect { subject }.to change { list.reload.exclusive }.from(false).to(true) + end + + context 'when the list does not exist' do + it 'returns http not found' do + put '/api/v1/lists/-1', headers: headers, params: params + + expect(response).to have_http_status(404) + end + end + + context 'when the list belongs to another user' do + let(:list) { Fabricate(:list) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE /api/v1/lists/:id' do + subject do + delete "/api/v1/lists/#{list.id}", headers: headers + end + + let(:list) { Fabricate(:list, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'read read:lists' + + it 'deletes the list', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(List.where(id: list.id)).to_not exist + end + + context 'when the list does not exist' do + it 'returns http not found' do + delete '/api/v1/lists/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'when the list belongs to another user' do + let(:list) { Fabricate(:list) } + + 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/media_spec.rb b/spec/requests/api/v1/media_spec.rb new file mode 100644 index 00000000000..7253a9f1e85 --- /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: true 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: true 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: true 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 00000000000..9a1d16200a2 --- /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(3, :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: 2 } } + + 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[2].id.to_s)) + expect(headers.find_link(%w(rel next)).href).to eq(api_v1_mutes_url(limit: params[:limit], max_id: mutes[1].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 2 + expect(body[0][:id]).to eq mutes[2].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 00000000000..7a879c35b7c --- /dev/null +++ b/spec/requests/api/v1/notifications_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Notifications' do + let(:user) { Fabricate(:user, account_attributes: { username: 'alice' }) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:notifications write:notifications' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/notifications' do + subject do + get '/api/v1/notifications', headers: headers, params: params + end + + let(:bob) { Fabricate(:user) } + let(:tom) { Fabricate(:user) } + let(:params) { {} } + + before do + first_status = PostStatusService.new.call(user.account, text: 'Test') + ReblogService.new.call(bob.account, first_status) + mentioning_status = PostStatusService.new.call(bob.account, text: 'Hello @alice') + mentioning_status.mentions.first + FavouriteService.new.call(bob.account, first_status) + FavouriteService.new.call(tom.account, first_status) + FollowService.new.call(bob.account, user.account) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:notifications' + + context 'with no options' do + it 'returns expected notification types', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_json_types).to include 'reblog' + expect(body_json_types).to include 'mention' + expect(body_json_types).to include 'favourite' + expect(body_json_types).to include 'follow' + end + end + + context 'with account_id param' do + let(:params) { { account_id: tom.account.id } } + + it 'returns only notifications from specified user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_json_account_ids.uniq).to eq [tom.account.id.to_s] + end + + def body_json_account_ids + body_as_json.map { |x| x[:account][:id] } + end + end + + context 'with invalid account_id param' do + let(:params) { { account_id: 'foo' } } + + it 'returns nothing', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq 0 + end + end + + context 'with exclude_types param' do + let(:params) { { exclude_types: %w(mention) } } + + it 'returns everything but excluded type', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json.size).to_not eq 0 + expect(body_json_types.uniq).to_not include 'mention' + end + end + + context 'with types param' do + let(:params) { { types: %w(mention) } } + + it 'returns only requested type', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_json_types.uniq).to eq ['mention'] + end + end + + context 'with limit param' do + let(:params) { { limit: 3 } } + + it 'returns the requested number of notifications paginated', :aggregate_failures do + subject + + notifications = user.account.notifications + + expect(body_as_json.size).to eq(params[:limit]) + expect(response.headers['Link'].find_link(%w(rel prev)).href).to eq(api_v1_notifications_url(limit: params[:limit], min_id: notifications.last.id.to_s)) + expect(response.headers['Link'].find_link(%w(rel next)).href).to eq(api_v1_notifications_url(limit: params[:limit], max_id: notifications[2].id.to_s)) + end + end + + def body_json_types + body_as_json.pluck(:type) + end + end + + describe 'GET /api/v1/notifications/:id' do + subject do + get "/api/v1/notifications/#{notification.id}", headers: headers + end + + let(:notification) { Fabricate(:notification, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'write write:notifications' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + context 'when notification belongs to someone else' do + let(:notification) { Fabricate(:notification) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/notifications/:id/dismiss' do + subject do + post "/api/v1/notifications/#{notification.id}/dismiss", headers: headers + end + + let!(:notification) { Fabricate(:notification, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'read read:notifications' + + it 'destroys the notification' do + subject + + expect(response).to have_http_status(200) + expect { notification.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context 'when notification belongs to someone else' do + let(:notification) { Fabricate(:notification) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/notifications/clear' do + subject do + post '/api/v1/notifications/clear', headers: headers + end + + before do + Fabricate.times(3, :notification, account: user.account) + end + + it_behaves_like 'forbidden for wrong scope', 'read read:notifications' + + it 'clears notifications for the account' do + subject + + expect(user.account.reload.notifications).to be_empty + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/requests/api/v1/polls_spec.rb b/spec/requests/api/v1/polls_spec.rb new file mode 100644 index 00000000000..1c8a818d596 --- /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/profiles_spec.rb b/spec/requests/api/v1/profiles_spec.rb new file mode 100644 index 00000000000..26a9b848e56 --- /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 00000000000..ba3d2b3060e --- /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/statuses/bookmarks_spec.rb b/spec/requests/api/v1/statuses/bookmarks_spec.rb new file mode 100644 index 00000000000..d3007740a5d --- /dev/null +++ b/spec/requests/api/v1/statuses/bookmarks_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Bookmarks' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'write:bookmarks' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'POST /api/v1/statuses/:status_id/bookmark' do + subject do + post "/api/v1/statuses/#{status.id}/bookmark", headers: headers + end + + let(:status) { Fabricate(:status) } + + it_behaves_like 'forbidden for wrong scope', 'read' + + context 'with public status' do + it 'bookmarks the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.bookmarked?(status)).to be true + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, bookmarked: true) + ) + end + end + + context 'with private status of not-followed account' do + let(:status) { Fabricate(:status, visibility: :private) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'with private status of followed account' do + let(:status) { Fabricate(:status, visibility: :private) } + + before do + user.account.follow!(status.account) + end + + it 'bookmarks the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.bookmarked?(status)).to be true + end + end + + context 'when the status does not exist' do + it 'returns http not found' do + post '/api/v1/statuses/-1/bookmark', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /api/v1/statuses/:status_id/unbookmark' do + subject do + post "/api/v1/statuses/#{status.id}/unbookmark", headers: headers + end + + let(:status) { Fabricate(:status) } + + it_behaves_like 'forbidden for wrong scope', 'read' + + context 'with public status' do + context 'when the status was previously bookmarked' do + before do + Bookmark.find_or_create_by!(account: user.account, status: status) + end + + it 'unbookmarks the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.bookmarked?(status)).to be false + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, bookmarked: false) + ) + end + end + + context 'when the requesting user was blocked by the status author' do + let(:status) { Fabricate(:status) } + + before do + Bookmark.find_or_create_by!(account: user.account, status: status) + status.account.block!(user.account) + end + + it 'unbookmarks the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.bookmarked?(status)).to be false + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, bookmarked: false) + ) + end + end + + context 'when the status is not bookmarked' do + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + end + + context 'with private status that was not bookmarked' do + let(:status) { Fabricate(:status, visibility: :private) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/statuses/favourites_spec.rb b/spec/requests/api/v1/statuses/favourites_spec.rb new file mode 100644 index 00000000000..ac5e86f2970 --- /dev/null +++ b/spec/requests/api/v1/statuses/favourites_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Favourites' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'write:favourites' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'POST /api/v1/statuses/:status_id/favourite' do + subject do + post "/api/v1/statuses/#{status.id}/favourite", headers: headers + end + + let(:status) { Fabricate(:status) } + + it_behaves_like 'forbidden for wrong scope', 'read read:favourites' + + context 'with public status' do + it 'favourites the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.favourited?(status)).to be true + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, favourites_count: 1, favourited: true) + ) + end + end + + context 'with private status of not-followed account' do + let(:status) { Fabricate(:status, visibility: :private) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'with private status of followed account' do + let(:status) { Fabricate(:status, visibility: :private) } + + before do + user.account.follow!(status.account) + end + + it 'favourites the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.favourited?(status)).to be true + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /api/v1/statuses/:status_id/unfavourite' do + subject do + post "/api/v1/statuses/#{status.id}/unfavourite", headers: headers + end + + let(:status) { Fabricate(:status) } + + around do |example| + Sidekiq::Testing.fake! do + example.run + end + end + + it_behaves_like 'forbidden for wrong scope', 'read read:favourites' + + context 'with public status' do + before do + FavouriteService.new.call(user.account, status) + end + + it 'unfavourites the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.favourited?(status)).to be true + + UnfavouriteWorker.drain + expect(user.account.favourited?(status)).to be false + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, favourites_count: 0, favourited: false) + ) + end + end + + context 'when the requesting user was blocked by the status author' do + before do + FavouriteService.new.call(user.account, status) + status.account.block!(user.account) + end + + it 'unfavourites the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.favourited?(status)).to be true + + UnfavouriteWorker.drain + expect(user.account.favourited?(status)).to be false + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, favourites_count: 0, favourited: false) + ) + end + end + + context 'when status is not favourited' do + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + + context 'with private status that was not favourited' do + let(:status) { Fabricate(:status, visibility: :private) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/statuses/pins_spec.rb b/spec/requests/api/v1/statuses/pins_spec.rb new file mode 100644 index 00000000000..db07fa424f7 --- /dev/null +++ b/spec/requests/api/v1/statuses/pins_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Pins' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'write:accounts' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'POST /api/v1/statuses/:status_id/pin' do + subject do + post "/api/v1/statuses/#{status.id}/pin", headers: headers + end + + let(:status) { Fabricate(:status, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'read read:accounts' + + context 'when the status is public' do + it 'pins the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.pinned?(status)).to be true + end + + it 'return json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, pinned: true) + ) + end + end + + context 'when the status is private' do + let(:status) { Fabricate(:status, account: user.account, visibility: :private) } + + it 'pins the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.pinned?(status)).to be true + end + end + + context 'when the status belongs to somebody else' do + let(:status) { Fabricate(:status) } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the status does not exist' do + it 'returns http not found' do + post '/api/v1/statuses/-1/pin', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /api/v1/statuses/:status_id/unpin' do + subject do + post "/api/v1/statuses/#{status.id}/unpin", headers: headers + end + + let(:status) { Fabricate(:status, account: user.account) } + + context 'when the status is pinned' do + before do + Fabricate(:status_pin, status: status, account: user.account) + end + + it 'unpins the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.pinned?(status)).to be false + end + + it 'return json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, pinned: false) + ) + end + end + + context 'when the status is not pinned' do + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + + context 'when the status does not exist' do + it 'returns http not found' do + post '/api/v1/statuses/-1/unpin', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end +end diff --git a/spec/requests/api/v1/statuses/sources_spec.rb b/spec/requests/api/v1/statuses/sources_spec.rb new file mode 100644 index 00000000000..723b81905e9 --- /dev/null +++ b/spec/requests/api/v1/statuses/sources_spec.rb @@ -0,0 +1,72 @@ +# 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, + }) + 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, + }) + 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/suggestions_spec.rb b/spec/requests/api/v1/suggestions_spec.rb new file mode 100644 index 00000000000..42b7f86629b --- /dev/null +++ b/spec/requests/api/v1/suggestions_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Suggestions' 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/v1/suggestions' do + subject do + get '/api/v1/suggestions', headers: headers, params: params + end + + 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) + 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 accounts' do + subject + + body = body_as_json + + expect(body.size).to eq 2 + expect(body.pluck(:id)).to match_array([bob, jeff].map { |i| i.id.to_s }) + 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 1 + 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 'DELETE /api/v1/suggestions/:id' do + subject 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) } + + 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) + end + + it_behaves_like 'forbidden for wrong scope', 'write' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + 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) + 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/tags_spec.rb b/spec/requests/api/v1/tags_spec.rb new file mode 100644 index 00000000000..db74a6f0373 --- /dev/null +++ b/spec/requests/api/v1/tags_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Tags' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'write:follows' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/tags/:id' do + subject do + get "/api/v1/tags/#{name}" + end + + context 'when the tag exists' do + let!(:tag) { Fabricate(:tag) } + let(:name) { tag.name } + + it 'returns the tag', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:name]).to eq(name) + end + end + + context 'when the tag does not exist' do + let(:name) { 'hoge' } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + + context 'when the tag name is invalid' do + let(:name) { 'tag-name' } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/tags/:id/follow' do + subject do + post "/api/v1/tags/#{name}/follow", headers: headers + end + + let!(:tag) { Fabricate(:tag) } + let(:name) { tag.name } + + it_behaves_like 'forbidden for wrong scope', 'read read:follows' + + context 'when the tag exists' do + it 'creates follow', :aggregate_failures do + subject + + expect(response).to have_http_status(:success) + expect(TagFollow.where(tag: tag, account: user.account)).to exist + end + end + + context 'when the tag does not exist' do + let(:name) { 'hoge' } + + it 'creates a new tag with the specified name', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(Tag.where(name: name)).to exist + expect(TagFollow.where(tag: Tag.find_by(name: name), account: user.account)).to exist + end + end + + context 'when the tag name is invalid' do + let(:name) { 'tag-name' } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when the Authorization header is missing' do + let(:headers) { {} } + let(:name) { 'unauthorized' } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST #unfollow' do + subject do + post "/api/v1/tags/#{name}/unfollow", headers: headers + end + + let(:name) { tag.name } + let!(:tag) { Fabricate(:tag, name: 'foo') } + + before do + Fabricate(:tag_follow, account: user.account, tag: tag) + end + + it_behaves_like 'forbidden for wrong scope', 'read read:follows' + + it 'removes the follow', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(TagFollow.where(tag: tag, account: user.account)).to_not exist + end + + context 'when the tag name is invalid' do + let(:name) { 'tag-name' } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when the Authorization header is missing' do + let(:headers) { {} } + let(:name) { 'unauthorized' } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end +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 00000000000..5834b909557 --- /dev/null +++ b/spec/requests/api/v1/timelines/home_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Home' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/timelines/home' do + subject do + get '/api/v1/timelines/home', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'write write:statuses' + + context 'when the timeline is available' do + let(:home_statuses) { bob.statuses + ana.statuses } + let!(:bob) { Fabricate(:account) } + let!(:tim) { Fabricate(:account) } + let!(:ana) { Fabricate(:account) } + + before do + user.account.follow!(bob) + user.account.follow!(ana) + PostStatusService.new.call(bob, text: 'New toot from bob.') + PostStatusService.new.call(tim, text: 'New toot from tim.') + PostStatusService.new.call(ana, text: 'New toot from ana.') + end + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the statuses of followed users' do + subject + + expect(body_as_json.pluck(:id)).to match_array(home_statuses.map { |status| status.id.to_s }) + end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'returns only the requested number of statuses' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + + it 'sets the correct pagination headers', :aggregate_failures do + subject + + headers = response.headers['Link'] + + expect(headers.find_link(%w(rel prev)).href).to eq(api_v1_timelines_home_url(limit: 1, min_id: ana.statuses.first.id.to_s)) + expect(headers.find_link(%w(rel next)).href).to eq(api_v1_timelines_home_url(limit: 1, max_id: ana.statuses.first.id.to_s)) + end + end + end + + context 'when the timeline is regenerating' do + let(:timeline) { instance_double(HomeFeed, regenerating?: true, get: []) } + + before do + allow(HomeFeed).to receive(:new).and_return(timeline) + end + + it 'returns http partial content' do + subject + + expect(response).to have_http_status(206) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + + context 'without a user context' do + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: scopes) } + + it 'returns http unprocessable entity', :aggregate_failures do + subject + + expect(response).to have_http_status(422) + expect(response.headers['Link']).to be_nil + end + end + end +end diff --git a/spec/requests/api/v1/timelines/public_spec.rb b/spec/requests/api/v1/timelines/public_spec.rb new file mode 100644 index 00000000000..c436262407d --- /dev/null +++ b/spec/requests/api/v1/timelines/public_spec.rb @@ -0,0 +1,116 @@ +# 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] } + + 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 00000000000..a118af13e2f --- /dev/null +++ b/spec/requests/api/v1/timelines/tag_spec.rb @@ -0,0 +1,112 @@ +# 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 + + 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/filters_spec.rb b/spec/requests/api/v2/filters/filters_spec.rb new file mode 100644 index 00000000000..2ee24d80951 --- /dev/null +++ b/spec/requests/api/v2/filters/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(3, :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/media_spec.rb b/spec/requests/api/v2/media_spec.rb new file mode 100644 index 00000000000..89384d0ca36 --- /dev/null +++ b/spec/requests/api/v2/media_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Media API', paperclip_processing: true do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'write' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'POST /api/v2/media' do + it 'returns http success' do + post '/api/v2/media', headers: headers, params: { file: fixture_file_upload('attachment-jpg.123456_abcd', 'image/jpeg') } + expect(File.exist?(user.account.media_attachments.first.file.path(:small))).to be true + expect(response).to have_http_status(200) + 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 00000000000..6314f43aafe --- /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/backups_spec.rb b/spec/requests/backups_spec.rb new file mode 100644 index 00000000000..a6c2efe0db0 --- /dev/null +++ b/spec/requests/backups_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Backups' do + include RoutingHelper + + describe 'GET backups#download' do + let(:user) { Fabricate(:user) } + let(:backup) { Fabricate(:backup, user: user) } + + before do + sign_in user + end + + it 'Downloads a user backup' do + get download_backup_path(backup) + + expect(response).to redirect_to(backup_dump_url) + end + + def backup_dump_url + full_asset_url(backup.dump.url) + end + end +end diff --git a/spec/requests/cache_spec.rb b/spec/requests/cache_spec.rb new file mode 100644 index 00000000000..c391c8b3da9 --- /dev/null +++ b/spec/requests/cache_spec.rb @@ -0,0 +1,686 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module TestEndpoints + # Endpoints that do not include authorization-dependent results + # and should be cacheable no matter what. + ALWAYS_CACHED = %w( + /.well-known/host-meta + /.well-known/nodeinfo + /nodeinfo/2.0 + /manifest + /custom.css + /actor + /api/v1/instance/extended_description + /api/v1/instance/rules + /api/v1/instance/peers + /api/v1/instance + /api/v2/instance + ).freeze + + # Endpoints that should be cachable when accessed anonymously but have a Vary + # on Cookie to prevent logged-in users from getting values from logged-out cache. + COOKIE_DEPENDENT_CACHABLE = %w( + / + /explore + /public + /about + /privacy-policy + /directory + /@alice + /@alice/110224538612341312 + /deck/home + ).freeze + + # Endpoints that should be cachable when accessed anonymously but have a Vary + # on Authorization to prevent logged-in users from getting values from logged-out cache. + AUTHORIZATION_DEPENDENT_CACHABLE = %w( + /api/v1/accounts/lookup?acct=alice + /api/v1/statuses/110224538612341312 + /api/v1/statuses/110224538612341312/context + /api/v1/polls/12345 + /api/v1/trends/statuses + /api/v1/directory + ).freeze + + # Private status that should only be returned with to a valid signature from + # a specific user. + # Should never be cached. + REQUIRE_SIGNATURE = %w( + /users/alice/statuses/110224538643211312 + ).freeze + + # Pages only available to logged-in users. + # Should never be cached. + REQUIRE_LOGIN = %w( + /settings/preferences/appearance + /settings/profile + /settings/featured_tags + /settings/export + /relationships + /filters + /statuses_cleanup + /auth/edit + /oauth/authorized_applications + /admin/dashboard + ).freeze + + # API endpoints only available to logged-in users. + # Should never be cached. + REQUIRE_TOKEN = %w( + /api/v1/announcements + /api/v1/timelines/home + /api/v1/notifications + /api/v1/bookmarks + /api/v1/favourites + /api/v1/follow_requests + /api/v1/conversations + /api/v1/statuses/110224538643211312 + /api/v1/statuses/110224538643211312/context + /api/v1/lists + /api/v2/filters + ).freeze + + # Pages that are only shown to logged-out users, and should never get cached + # because of CSRF protection. + REQUIRE_LOGGED_OUT = %w( + /invite/abcdef + /auth/sign_in + /auth/sign_up + /auth/password/new + /auth/confirmation/new + ).freeze + + # Non-exhaustive list of endpoints that feature language-dependent results + # and thus need to have a Vary on Accept-Language + LANGUAGE_DEPENDENT = %w( + / + /explore + /about + /api/v1/trends/statuses + ).freeze + + module AuthorizedFetch + # Endpoints that require a signature with AUTHORIZED_FETCH and LIMITED_FEDERATION_MODE + # and thus should not be cached in those modes. + REQUIRE_SIGNATURE = %w( + /users/alice + ).freeze + end + + module DisabledAnonymousAPI + # Endpoints that require a signature with DISALLOW_UNAUTHENTICATED_API_ACCESS + # and thus should not be cached in this mode. + REQUIRE_TOKEN = %w( + /api/v1/custom_emojis + ).freeze + end +end + +describe 'Caching behavior' do + shared_examples 'cachable response' do + it 'does not set cookies' 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 + end + end + + shared_examples 'non-cacheable response' do + it 'sets private cache control' do + expect(response.cache_control[:private]).to be_truthy + expect(response.cache_control[:no_store]).to be_truthy + end + end + + shared_examples 'non-cacheable error' do + it 'does not return HTTP success' 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') + end + end + + # Enable CSRF protection like it is in production, as it can cause cookies + # to be set and thus mess with cache. + around do |example| + old = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + + example.run + + ActionController::Base.allow_forgery_protection = old + end + + let(:alice) { Fabricate(:account, username: 'alice') } + 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) + Fabricate(:invite, code: 'abcdef') + Fabricate(:poll, status: status, account: alice, id: 12345) + # rubocop:enable Style/NumericLiterals + + user.account.follow!(alice) + end + + context 'when anonymously accessed' do + TestEndpoints::ALWAYS_CACHED.each do |endpoint| + describe endpoint do + before { get endpoint } + + it_behaves_like 'cachable response' + it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint) + end + end + + TestEndpoints::COOKIE_DEPENDENT_CACHABLE.each do |endpoint| + describe endpoint do + before { get endpoint } + + 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') + end + + it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint) + end + end + + TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE.each do |endpoint| + describe endpoint do + before { get endpoint } + + 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') + end + + it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint) + end + end + + TestEndpoints::REQUIRE_LOGGED_OUT.each do |endpoint| + describe endpoint do + before { get endpoint } + + it_behaves_like 'non-cacheable response' + end + end + + (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::REQUIRE_LOGIN + TestEndpoints::REQUIRE_TOKEN).each do |endpoint| + describe endpoint do + before { get endpoint } + + it_behaves_like 'non-cacheable error' + 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 { get '/api/v1/instance/domain_blocks' } + + context 'when set to be publicly-available' do + let(:show_domain_blocks) { 'all' } + + it_behaves_like 'cachable response' + end + + context 'when allowed for local users only' do + let(:show_domain_blocks) { 'users' } + + it_behaves_like 'non-cacheable error' + end + + context 'when disabled' do + let(:show_domain_blocks) { 'disabled' } + + it_behaves_like 'non-cacheable error' + end + end + end + + context 'when logged in' do + before do + sign_in user, scope: :user + + # Unfortunately, devise's `sign_in` helper causes the `session` to be + # loaded in the next request regardless of whether it's actually accessed + # by the client code. + # + # So, we make an extra query to clear issue a session cookie instead. + # + # A less resource-intensive way to deal with that would be to generate the + # session cookie manually, but this seems pretty involved. + get '/' + end + + TestEndpoints::ALWAYS_CACHED.each do |endpoint| + describe endpoint do + before { get endpoint } + + it_behaves_like 'cachable response' + it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint) + end + end + + TestEndpoints::COOKIE_DEPENDENT_CACHABLE.each do |endpoint| + describe endpoint do + before { get endpoint } + + 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') + end + end + end + + TestEndpoints::REQUIRE_LOGIN.each do |endpoint| + 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 + end + end + + TestEndpoints::REQUIRE_LOGGED_OUT.each do |endpoint| + describe endpoint do + before { get endpoint } + + it_behaves_like 'non-cacheable error' + end + end + end + + context 'with an auth token' do + let!(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') } + + TestEndpoints::ALWAYS_CACHED.each do |endpoint| + describe endpoint do + before do + get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" } + end + + it_behaves_like 'cachable response' + it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint) + end + end + + TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE.each do |endpoint| + describe endpoint do + before do + get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" } + end + + 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') + end + end + end + + (TestEndpoints::REQUIRE_LOGGED_OUT + TestEndpoints::REQUIRE_TOKEN).each do |endpoint| + describe endpoint do + before 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 + 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 + get '/api/v1/instance/domain_blocks', headers: { 'Authorization' => "Bearer #{token.token}" } + end + + context 'when set to be publicly-available' do + let(:show_domain_blocks) { 'all' } + + it_behaves_like 'cachable response' + end + + 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 + end + + context 'when disabled' do + let(:show_domain_blocks) { 'disabled' } + + it_behaves_like 'non-cacheable error' + end + end + end + + context 'with a Signature header' do + let(:remote_actor) { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) } + let(:dummy_signature) { 'dummy-signature' } + + before do + remote_actor.follow!(alice) + end + + describe '/actor' do + before 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 + end + + TestEndpoints::REQUIRE_SIGNATURE.each do |endpoint| + describe endpoint do + before 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 + end + end + end + + context 'when enabling AUTHORIZED_FETCH mode' do + around do |example| + ClimateControl.modify AUTHORIZED_FETCH: 'true' do + example.run + end + end + + context 'when not providing a Signature' do + describe '/actor' do + before 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 + end + + (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint| + describe endpoint do + before do + get endpoint, headers: { 'Accept' => 'application/activity+json' } + end + + it_behaves_like 'non-cacheable error' + end + end + end + + context 'when providing a Signature' do + let(:remote_actor) { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) } + let(:dummy_signature) { 'dummy-signature' } + + before do + remote_actor.follow!(alice) + end + + describe '/actor' do + before 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 + end + + (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint| + describe endpoint do + before 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 + end + end + end + end + + context 'when enabling LIMITED_FEDERATION_MODE mode' do + around do |example| + ClimateControl.modify LIMITED_FEDERATION_MODE: 'true' do + old_limited_federation_mode = Rails.configuration.x.limited_federation_mode + Rails.configuration.x.limited_federation_mode = true + + example.run + + Rails.configuration.x.limited_federation_mode = old_limited_federation_mode + end + end + + context 'when not providing a Signature' do + describe '/actor' do + before 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 + end + + (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint| + describe endpoint do + before do + get endpoint, headers: { 'Accept' => 'application/activity+json' } + end + + it_behaves_like 'non-cacheable error' + end + end + end + + context 'when providing a Signature from an allowed domain' do + let(:remote_actor) { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) } + let(:dummy_signature) { 'dummy-signature' } + + before do + DomainAllow.create!(domain: remote_actor.domain) + remote_actor.follow!(alice) + end + + describe '/actor' do + before 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 + end + + (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint| + describe endpoint do + before 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 + end + end + end + + context 'when providing a Signature from a non-allowed domain' do + let(:remote_actor) { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) } + let(:dummy_signature) { 'dummy-signature' } + + describe '/actor' do + before 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 + end + + (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint| + describe endpoint do + before do + get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' } + end + + it_behaves_like 'non-cacheable error' + end + end + 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 + + context 'when anonymously accessed' do + TestEndpoints::ALWAYS_CACHED.each do |endpoint| + describe endpoint do + before { get endpoint } + + it_behaves_like 'cachable response' + it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint) + end + end + + TestEndpoints::REQUIRE_LOGGED_OUT.each do |endpoint| + describe endpoint do + before { get endpoint } + + it_behaves_like 'non-cacheable response' + end + end + + (TestEndpoints::REQUIRE_TOKEN + TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE + TestEndpoints::DisabledAnonymousAPI::REQUIRE_TOKEN).each do |endpoint| + describe endpoint do + before { get endpoint } + + it_behaves_like 'non-cacheable error' + end + end + end + + context 'with an auth token' do + let!(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') } + + TestEndpoints::ALWAYS_CACHED.each do |endpoint| + describe endpoint do + before do + get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" } + end + + it_behaves_like 'cachable response' + it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint) + end + end + + TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE.each do |endpoint| + describe endpoint do + before do + get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" } + end + + 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') + end + end + end + + (TestEndpoints::REQUIRE_LOGGED_OUT + TestEndpoints::REQUIRE_TOKEN + TestEndpoints::DisabledAnonymousAPI::REQUIRE_TOKEN).each do |endpoint| + describe endpoint do + before 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 + end + end + end + end +end diff --git a/spec/requests/catch_all_route_request_spec.rb b/spec/requests/catch_all_route_request_spec.rb index dcfc1bf4bcf..e600bedfe07 100644 --- a/spec/requests/catch_all_route_request_spec.rb +++ b/spec/requests/catch_all_route_request_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe 'The catch all route' do diff --git a/spec/requests/content_security_policy_spec.rb b/spec/requests/content_security_policy_spec.rb new file mode 100644 index 00000000000..7eb27d61d61 --- /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' https: data: blob: https://cb6e6126.ngrok.io", + "style-src 'self' https://cb6e6126.ngrok.io 'nonce-ZbA+JmE7+bK8F5qvADZHuQ=='", + "media-src 'self' https: 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 https://cb6e6126.ngrok.io ws://localhost:4000", + "script-src 'self' https://cb6e6126.ngrok.io 'wasm-unsafe-eval'" + ) + end +end diff --git a/spec/requests/follower_accounts_spec.rb b/spec/requests/follower_accounts_spec.rb new file mode 100644 index 00000000000..52e86e13fea --- /dev/null +++ b/spec/requests/follower_accounts_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'FollowerAccountsController' do + describe 'The follower_accounts route' do + it "returns a http 'moved_permanently' code" do + get '/users/:username/followers' + + expect(response).to have_http_status(301) + end + end +end diff --git a/spec/requests/following_accounts_spec.rb b/spec/requests/following_accounts_spec.rb new file mode 100644 index 00000000000..f0955ceb36b --- /dev/null +++ b/spec/requests/following_accounts_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'FollowingAccountsController' do + describe 'The following_accounts route' do + it "returns a http 'moved_permanently' code" do + get '/users/:username/following' + + expect(response).to have_http_status(301) + end + end +end diff --git a/spec/requests/host_meta_request_spec.rb b/spec/requests/host_meta_request_spec.rb index 60153ba8c92..ec26ecba7d9 100644 --- a/spec/requests/host_meta_request_spec.rb +++ b/spec/requests/host_meta_request_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe 'The host_meta route' do diff --git a/spec/requests/link_headers_spec.rb b/spec/requests/link_headers_spec.rb index c32e0f79a93..b822adbfb85 100644 --- a/spec/requests/link_headers_spec.rb +++ b/spec/requests/link_headers_spec.rb @@ -13,7 +13,7 @@ describe 'Link headers' do it 'contains webfinger url in link header' do link_header = link_header_with_type('application/jrd+json') - expect(link_header.href).to match 'http://www.example.com/.well-known/webfinger?resource=acct%3Atest%40cb6e6126.ngrok.io' + expect(link_header.href).to eq 'http://www.example.com/.well-known/webfinger?resource=acct%3Atest%40cb6e6126.ngrok.io' expect(link_header.attr_pairs.first).to eq %w(rel lrdd) end @@ -26,7 +26,7 @@ describe 'Link headers' do def link_header_with_type(type) LinkHeader.parse(response.headers['Link'].to_s).links.find do |link| - link.attr_pairs.any? { |pair| pair == ['type', type] } + link.attr_pairs.any?(['type', type]) end end end diff --git a/spec/requests/localization_spec.rb b/spec/requests/localization_spec.rb index 39eeee5f01e..b7fb53ed8d7 100644 --- a/spec/requests/localization_spec.rb +++ b/spec/requests/localization_spec.rb @@ -3,8 +3,10 @@ require 'rails_helper' describe 'Localization' do - after(:all) do - I18n.locale = I18n.default_locale + around do |example| + I18n.with_locale(I18n.locale) do + example.run + end end it 'uses a specific region when provided' do diff --git a/spec/requests/mail_subscriptions_spec.rb b/spec/requests/mail_subscriptions_spec.rb new file mode 100644 index 00000000000..cc6557cab01 --- /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 00000000000..27aa5ec506d --- /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/webfinger_request_spec.rb b/spec/requests/webfinger_request_spec.rb index 209fda72aad..68a1478bed6 100644 --- a/spec/requests/webfinger_request_spec.rb +++ b/spec/requests/webfinger_request_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe 'The webfinger route' do diff --git a/spec/routing/accounts_routing_spec.rb b/spec/routing/accounts_routing_spec.rb index 3f0e9b3e95d..8b2c124fd21 100644 --- a/spec/routing/accounts_routing_spec.rb +++ b/spec/routing/accounts_routing_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe 'Routes under accounts/' do diff --git a/spec/routing/well_known_routes_spec.rb b/spec/routing/well_known_routes_spec.rb index 7474633515f..8cf08c13c12 100644 --- a/spec/routing/well_known_routes_spec.rb +++ b/spec/routing/well_known_routes_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe 'Well Known routes' do 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 00000000000..65e1e4de1c9 --- /dev/null +++ b/spec/search/models/concerns/account_search_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe AccountSearch 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 00000000000..d35cfa56392 --- /dev/null +++ b/spec/search/models/concerns/account_statuses_search_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe AccountStatusesSearch 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 new file mode 100644 index 00000000000..2a3be821214 --- /dev/null +++ b/spec/serializers/activitypub/device_serializer_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ActivityPub::DeviceSerializer do + let(:serialization) do + JSON.parse( + ActiveModelSerializers::SerializableResource.new( + record, serializer: described_class + ).to_json + ) + end + let(:record) { Fabricate(:device) } + + describe 'type' do + it 'returns correct serialized type' do + expect(serialization['type']).to eq('Device') + end + end +end diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb new file mode 100644 index 00000000000..31ee31f132f --- /dev/null +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ActivityPub::NoteSerializer do + subject { JSON.parse(@serialization.to_json) } + + let!(:account) { Fabricate(:account) } + let!(:other) { Fabricate(:account) } + 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 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 + expect(subject['replies']['type']).to eql('Collection') + end + + it 'has a replies collection with a first Page' do + expect(subject['replies']['first']['type']).to eql('CollectionPage') + end + + it 'includes public self-replies in its replies collection' do + expect(subject['replies']['first']['items']).to include(reply_by_account_first.uri, reply_by_account_next.uri, reply_by_account_third.uri) + end + + it 'does not include replies from others in its replies collection' do + expect(subject['replies']['first']['items']).to_not include(reply_by_other_first.uri) + end + + it 'does not include replies with direct visibility in its replies collection' do + expect(subject['replies']['first']['items']).to_not include(reply_by_account_visibility_direct.uri) + end +end diff --git a/spec/serializers/activitypub/note_spec.rb b/spec/serializers/activitypub/note_spec.rb deleted file mode 100644 index 7ea47baef22..00000000000 --- a/spec/serializers/activitypub/note_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe ActivityPub::NoteSerializer do - subject { JSON.parse(@serialization.to_json) } - - let!(:account) { Fabricate(:account) } - let!(:other) { Fabricate(:account) } - let!(:parent) { Fabricate(:status, account: account, visibility: :public) } - let!(:reply1) { Fabricate(:status, account: account, thread: parent, visibility: :public) } - let!(:reply2) { Fabricate(:status, account: account, thread: parent, visibility: :public) } - let!(:reply3) { Fabricate(:status, account: other, thread: parent, visibility: :public) } - let!(:reply4) { Fabricate(:status, account: account, thread: parent, visibility: :public) } - let!(:reply5) { Fabricate(:status, account: account, thread: parent, visibility: :direct) } - - before(:each) do - @serialization = ActiveModelSerializers::SerializableResource.new(parent, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter) - end - - it 'has a Note type' do - expect(subject['type']).to eql('Note') - end - - it 'has a replies collection' do - expect(subject['replies']['type']).to eql('Collection') - end - - it 'has a replies collection with a first Page' do - expect(subject['replies']['first']['type']).to eql('CollectionPage') - end - - it 'includes public self-replies in its replies collection' do - expect(subject['replies']['first']['items']).to include(reply1.uri, reply2.uri, reply4.uri) - end - - it 'does not include replies from others in its replies collection' do - expect(subject['replies']['first']['items']).to_not include(reply3.uri) - end - - it 'does not include replies with direct visibility in its replies collection' do - expect(subject['replies']['first']['items']).to_not include(reply5.uri) - end -end diff --git a/spec/serializers/activitypub/one_time_key_serializer_spec.rb b/spec/serializers/activitypub/one_time_key_serializer_spec.rb new file mode 100644 index 00000000000..6fe1f06185e --- /dev/null +++ b/spec/serializers/activitypub/one_time_key_serializer_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ActivityPub::OneTimeKeySerializer do + let(:serialization) do + JSON.parse( + ActiveModelSerializers::SerializableResource.new( + record, serializer: described_class + ).to_json + ) + end + let(:record) { Fabricate(:one_time_key) } + + describe 'type' do + it 'returns correct serialized type' do + expect(serialization['type']).to eq('Curve25519Key') + end + end +end diff --git a/spec/serializers/activitypub/undo_like_serializer_spec.rb b/spec/serializers/activitypub/undo_like_serializer_spec.rb new file mode 100644 index 00000000000..43cf7192e45 --- /dev/null +++ b/spec/serializers/activitypub/undo_like_serializer_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ActivityPub::UndoLikeSerializer do + let(:serialization) do + JSON.parse( + ActiveModelSerializers::SerializableResource.new( + record, serializer: described_class + ).to_json + ) + end + let(:record) { Fabricate(:favourite) } + + describe 'type' do + it 'returns correct serialized type' do + expect(serialization['type']).to eq('Undo') + end + end +end diff --git a/spec/serializers/activitypub/update_poll_spec.rb b/spec/serializers/activitypub/update_poll_serializer_spec.rb similarity index 88% rename from spec/serializers/activitypub/update_poll_spec.rb rename to spec/serializers/activitypub/update_poll_serializer_spec.rb index 4360808b50d..14c24c70cc7 100644 --- a/spec/serializers/activitypub/update_poll_spec.rb +++ b/spec/serializers/activitypub/update_poll_serializer_spec.rb @@ -10,7 +10,7 @@ describe ActivityPub::UpdatePollSerializer do let!(:status) { Fabricate(:status, account: account, poll: poll) } before(:each) do - @serialization = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::UpdatePollSerializer, adapter: ActivityPub::Adapter) + @serialization = ActiveModelSerializers::SerializableResource.new(status, serializer: described_class, adapter: ActivityPub::Adapter) end it 'has a Update type' do diff --git a/spec/serializers/activitypub/vote_serializer_spec.rb b/spec/serializers/activitypub/vote_serializer_spec.rb new file mode 100644 index 00000000000..c329542d791 --- /dev/null +++ b/spec/serializers/activitypub/vote_serializer_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ActivityPub::VoteSerializer do + let(:serialization) do + JSON.parse( + ActiveModelSerializers::SerializableResource.new( + record, serializer: described_class + ).to_json + ) + end + let(:record) { Fabricate(:poll_vote) } + + describe 'type' do + it 'returns correct serialized type' do + expect(serialization['type']).to eq('Create') + end + end +end diff --git a/spec/serializers/rest/account_serializer_spec.rb b/spec/serializers/rest/account_serializer_spec.rb index 528639943c8..e399e88f37c 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: REST::AccountSerializer).to_json) } + subject { JSON.parse(ActiveModelSerializers::SerializableResource.new(account, serializer: described_class).to_json) } 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 new file mode 100644 index 00000000000..e0e70a3b846 --- /dev/null +++ b/spec/serializers/rest/encrypted_message_serializer_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe REST::EncryptedMessageSerializer do + let(:serialization) do + JSON.parse( + ActiveModelSerializers::SerializableResource.new( + record, serializer: described_class + ).to_json + ) + end + let(:record) { Fabricate(:encrypted_message) } + + describe 'account' do + it 'returns the associated account' do + expect(serialization['account_id']).to eq(record.from_account.id.to_s) + end + end +end diff --git a/spec/serializers/rest/instance_serializer_spec.rb b/spec/serializers/rest/instance_serializer_spec.rb new file mode 100644 index 00000000000..15a5de18dd0 --- /dev/null +++ b/spec/serializers/rest/instance_serializer_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe REST::InstanceSerializer do + let(:serialization) do + JSON.parse( + ActiveModelSerializers::SerializableResource.new( + record, serializer: described_class + ).to_json + ) + end + let(:record) { InstancePresenter.new } + + describe 'usage' do + it 'returns recent usage data' do + expect(serialization['usage']).to eq({ 'users' => { 'active_month' => 0 } }) + 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 new file mode 100644 index 00000000000..cf9416f0328 --- /dev/null +++ b/spec/serializers/rest/keys/claim_result_serializer_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe REST::Keys::ClaimResultSerializer do + let(:serialization) do + JSON.parse( + ActiveModelSerializers::SerializableResource.new( + record, serializer: described_class + ).to_json + ) + end + let(:record) { Keys::ClaimService::Result.new(Account.new(id: 123), 456) } + + describe 'account' do + it 'returns the associated account' do + expect(serialization['account_id']).to eq('123') + end + end +end diff --git a/spec/serializers/rest/keys/device_serializer_spec.rb b/spec/serializers/rest/keys/device_serializer_spec.rb new file mode 100644 index 00000000000..c15e197cb8f --- /dev/null +++ b/spec/serializers/rest/keys/device_serializer_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe REST::Keys::DeviceSerializer do + let(:serialization) do + JSON.parse( + ActiveModelSerializers::SerializableResource.new( + record, serializer: described_class + ).to_json + ) + end + let(:record) { Device.new(name: 'Device name') } + + describe 'name' do + it 'returns the name' do + expect(serialization['name']).to eq('Device name') + end + end +end diff --git a/spec/serializers/rest/keys/query_result_serializer_spec.rb b/spec/serializers/rest/keys/query_result_serializer_spec.rb new file mode 100644 index 00000000000..983780ae98f --- /dev/null +++ b/spec/serializers/rest/keys/query_result_serializer_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe REST::Keys::QueryResultSerializer do + let(:serialization) do + JSON.parse( + ActiveModelSerializers::SerializableResource.new( + record, serializer: described_class + ).to_json + ) + end + let(:record) { Keys::QueryService::Result.new(Account.new(id: 123), []) } + + describe 'account' do + it 'returns the associated account id' do + expect(serialization['account_id']).to eq('123') + end + end +end diff --git a/spec/serializers/rest/suggestion_serializer_spec.rb b/spec/serializers/rest/suggestion_serializer_spec.rb new file mode 100644 index 00000000000..b3c086208db --- /dev/null +++ b/spec/serializers/rest/suggestion_serializer_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe REST::SuggestionSerializer do + let(:serialization) do + JSON.parse( + ActiveModelSerializers::SerializableResource.new( + record, serializer: described_class + ).to_json + ) + end + let(:record) do + AccountSuggestions::Suggestion.new( + account: account, + source: 'SuggestionSource' + ) + end + let(:account) { Fabricate(:account) } + + describe 'account' do + it 'returns the associated account' do + expect(serialization['account']['id']).to eq(account.id.to_s) + end + end +end diff --git a/spec/services/account_search_service_spec.rb b/spec/services/account_search_service_spec.rb index 45e19d1ef70..4f89cd220c8 100644 --- a/spec/services/account_search_service_spec.rb +++ b/spec/services/account_search_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe AccountSearchService, type: :service do @@ -18,7 +20,7 @@ describe AccountSearchService, type: :service do end end - context 'searching for a simple term that is not an exact match' do + context 'when searching for a simple term that is not an exact match' do it 'does not return a nil entry in the array for the exact match' do account = Fabricate(:account, username: 'matchingusername') results = subject.call('match', nil, limit: 5) @@ -51,25 +53,25 @@ describe AccountSearchService, type: :service do context 'when there is a domain but no exact match' do it 'follows the remote account when resolve is true' do - service = double(call: nil) + 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 it 'does not follow the remote account when resolve is false' do - service = double(call: nil) + 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 @@ -77,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 a30e14ab6f5..f7a88a9172f 100644 --- a/spec/services/account_statuses_cleanup_service_spec.rb +++ b/spec/services/account_statuses_cleanup_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' describe AccountStatusesCleanupService, type: :service do @@ -18,13 +20,13 @@ describe AccountStatusesCleanupService, type: :service do let!(:another_old_status) { Fabricate(:status, created_at: 1.year.ago, account: account) } let!(:recent_status) { Fabricate(:status, created_at: 1.day.ago, account: account) } - context 'given a budget of 1' do + context 'when given a budget of 1' do it 'reports 1 deleted toot' do expect(subject.call(account_policy, 1)).to eq 1 end end - context 'given a normal budget of 10' do + context 'when given a normal budget of 10' do it 'reports 3 deleted statuses' do expect(subject.call(account_policy, 10)).to eq 3 end diff --git a/spec/services/activitypub/fetch_featured_collection_service_spec.rb b/spec/services/activitypub/fetch_featured_collection_service_spec.rb index d9266ffc2e5..5975c81a101 100644 --- a/spec/services/activitypub/fetch_featured_collection_service_spec.rb +++ b/spec/services/activitypub/fetch_featured_collection_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do @@ -7,33 +9,33 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do let!(:known_status) { Fabricate(:status, account: actor, uri: 'https://example.com/account/pinned/1') } - let(:status_json_1) do + let(:status_json_pinned_known) do { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Note', - id: 'https://example.com/account/pinned/1', + id: 'https://example.com/account/pinned/known', content: 'foo', attributedTo: actor.uri, to: 'https://www.w3.org/ns/activitystreams#Public', } end - let(:status_json_2) do + let(:status_json_pinned_unknown_inlined) do { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Note', - id: 'https://example.com/account/pinned/2', + id: 'https://example.com/account/pinned/unknown-inlined', content: 'foo', attributedTo: actor.uri, to: 'https://www.w3.org/ns/activitystreams#Public', } end - let(:status_json_4) do + let(:status_json_pinned_unknown_unreachable) do { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Note', - id: 'https://example.com/account/pinned/4', + id: 'https://example.com/account/pinned/unknown-reachable', content: 'foo', attributedTo: actor.uri, to: 'https://www.w3.org/ns/activitystreams#Public', @@ -42,10 +44,10 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do let(:items) do [ - 'https://example.com/account/pinned/1', # known - status_json_2, # unknown inlined - 'https://example.com/account/pinned/3', # unknown unreachable - 'https://example.com/account/pinned/4', # unknown reachable + '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 ] end @@ -60,16 +62,20 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do shared_examples 'sets pinned posts' do before do - stub_request(:get, 'https://example.com/account/pinned/1').to_return(status: 200, body: Oj.dump(status_json_1)) - stub_request(:get, 'https://example.com/account/pinned/2').to_return(status: 200, body: Oj.dump(status_json_2)) - stub_request(:get, 'https://example.com/account/pinned/3').to_return(status: 404) - stub_request(:get, 'https://example.com/account/pinned/4').to_return(status: 200, body: Oj.dump(status_json_4)) + 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)) subject.call(actor, note: true, hashtag: false) end it 'sets expected posts as pinned posts' do - expect(actor.pinned_statuses.pluck(:uri)).to match_array ['https://example.com/account/pinned/1', 'https://example.com/account/pinned/2', 'https://example.com/account/pinned/4'] + expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly( + 'https://example.com/account/pinned/known', + 'https://example.com/account/pinned/unknown-inlined', + 'https://example.com/account/pinned/unknown-reachable' + ) end end diff --git a/spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb b/spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb index 4f828bacc60..071e4d92d59 100644 --- a/spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb +++ b/spec/services/activitypub/fetch_featured_tags_collection_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::FetchFeaturedTagsCollectionService, type: :service do diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb index ec6f1f41d8f..ac7484d96d1 100644 --- a/spec/services/activitypub/fetch_remote_account_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do - subject { ActivityPub::FetchRemoteAccountService.new } + subject { described_class.new } let!(:actor) do { diff --git a/spec/services/activitypub/fetch_remote_actor_service_spec.rb b/spec/services/activitypub/fetch_remote_actor_service_spec.rb index 20117c66d04..93d31b69d51 100644 --- a/spec/services/activitypub/fetch_remote_actor_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_actor_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do - subject { ActivityPub::FetchRemoteActorService.new } + subject { described_class.new } let!(:actor) do { diff --git a/spec/services/activitypub/fetch_remote_key_service_spec.rb b/spec/services/activitypub/fetch_remote_key_service_spec.rb index 3186c4270d7..e210d20ec77 100644 --- a/spec/services/activitypub/fetch_remote_key_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_key_service_spec.rb @@ -1,12 +1,24 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do - subject { ActivityPub::FetchRemoteKeyService.new } + subject { described_class.new } let(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } let(:public_key_pem) do - "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu3L4vnpNLzVH31MeWI39\n4F0wKeJFsLDAsNXGeOu0QF2x+h1zLWZw/agqD2R3JPU9/kaDJGPIV2Sn5zLyUA9S\n6swCCMOtn7BBR9g9sucgXJmUFB0tACH2QSgHywMAybGfmSb3LsEMNKsGJ9VsvYoh\n8lDET6X4Pyw+ZJU0/OLo/41q9w+OrGtlsTm/PuPIeXnxa6BLqnDaxC+4IcjG/FiP\nahNCTINl/1F/TgSSDZ4Taf4U9XFEIFw8wmgploELozzIzKq+t8nhQYkgAkt64euW\npva3qL5KD1mTIZQEP+LZvh3s2WHrLi3fhbdRuwQ2c0KkJA2oSTFPDpqqbPGZ3Qvu\nHQIDAQAB\n-----END PUBLIC KEY-----\n" + <<~TEXT + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu3L4vnpNLzVH31MeWI39 + 4F0wKeJFsLDAsNXGeOu0QF2x+h1zLWZw/agqD2R3JPU9/kaDJGPIV2Sn5zLyUA9S + 6swCCMOtn7BBR9g9sucgXJmUFB0tACH2QSgHywMAybGfmSb3LsEMNKsGJ9VsvYoh + 8lDET6X4Pyw+ZJU0/OLo/41q9w+OrGtlsTm/PuPIeXnxa6BLqnDaxC+4IcjG/FiP + ahNCTINl/1F/TgSSDZ4Taf4U9XFEIFw8wmgploELozzIzKq+t8nhQYkgAkt64euW + pva3qL5KD1mTIZQEP+LZvh3s2WHrLi3fhbdRuwQ2c0KkJA2oSTFPDpqqbPGZ3Qvu + HQIDAQAB + -----END PUBLIC KEY----- + TEXT end let(:public_key_id) { 'https://example.com/alice#main-key' } diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb index 6e47392b356..826b67d8840 100644 --- a/spec/services/activitypub/fetch_remote_status_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do @@ -224,12 +226,12 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do end end - context 'statuses referencing other statuses' do + context 'with statuses referencing other statuses' do before do stub_const 'ActivityPub::FetchRemoteStatusService::DISCOVERIES_PER_REQUEST', 5 end - context 'using inReplyTo' do + context 'when using inReplyTo' do let(:object) do { '@context': 'https://www.w3.org/ns/activitystreams', @@ -265,7 +267,7 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do end end - context 'using replies' do + context 'when using replies' do let(:object) do { '@context': 'https://www.w3.org/ns/activitystreams', diff --git a/spec/services/activitypub/fetch_replies_service_spec.rb b/spec/services/activitypub/fetch_replies_service_spec.rb index 0231a5e9abc..bf8e2967643 100644 --- a/spec/services/activitypub/fetch_replies_service_spec.rb +++ b/spec/services/activitypub/fetch_replies_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::FetchRepliesService, type: :service do diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb index 78282e4537c..c02a0800a3c 100644 --- a/spec/services/activitypub/process_account_service_spec.rb +++ b/spec/services/activitypub/process_account_service_spec.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::ProcessAccountService, type: :service do subject { described_class.new } - context 'property values' do + context 'with property values' do let(:payload) do { id: 'https://foo.test', @@ -80,7 +82,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do account.suspend!(origin: suspension_origin) end - context 'locally' do + context 'when locally' do let(:suspension_origin) { :local } it 'does not unsuspend it' do @@ -92,7 +94,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end end - context 'remotely' do + context 'when remotely' do let(:suspension_origin) { :remote } it 'unsuspends it' do @@ -110,12 +112,8 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end end - context 'discovering many subdomains in a short timeframe' do - before do - stub_const 'ActivityPub::ProcessAccountService::SUBDOMAINS_RATELIMIT', 5 - end - - let(:subject) do + context 'when discovering many subdomains in a short timeframe' do + subject do 8.times do |i| domain = "test#{i}.testdomain.com" json = { @@ -127,6 +125,10 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end end + before do + stub_const 'ActivityPub::ProcessAccountService::SUBDOMAINS_RATELIMIT', 5 + end + it 'creates at least some accounts' do expect { subject }.to change { Account.remote.count }.by_at_least(2) end @@ -136,11 +138,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end end - context 'accounts referencing other accounts' do - before do - stub_const 'ActivityPub::ProcessAccountService::DISCOVERIES_PER_REQUEST', 5 - end - + context 'when Accounts referencing other accounts' do let(:payload) do { '@context': ['https://www.w3.org/ns/activitystreams'], @@ -153,6 +151,8 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end before do + stub_const 'ActivityPub::ProcessAccountService::DISCOVERIES_PER_REQUEST', 5 + 8.times do |i| actor_json = { '@context': ['https://www.w3.org/ns/activitystreams'], @@ -181,7 +181,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do '@context': ['https://www.w3.org/ns/activitystreams'], id: "https://foo.test/users/#{i}/featured", type: 'OrderedCollection', - totelItems: 1, + totalItems: 1, orderedItems: [status_json], }.with_indifferent_access webfinger = { diff --git a/spec/services/activitypub/process_collection_service_spec.rb b/spec/services/activitypub/process_collection_service_spec.rb index c7d0bb92af0..02011afea05 100644 --- a/spec/services/activitypub/process_collection_service_spec.rb +++ b/spec/services/activitypub/process_collection_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::ProcessCollectionService, type: :service do @@ -68,7 +70,7 @@ 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) + allow_any_instance_of(ActivityPub::LinkedDataSignature).to receive(:verify_actor!).and_return(nil) expect(ActivityPub::Activity).to_not receive(:factory) subject.call(json, forwarder) @@ -77,7 +79,7 @@ RSpec.describe ActivityPub::ProcessCollectionService, type: :service do 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) + allow_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)) subject.call(json, forwarder) @@ -86,7 +88,7 @@ RSpec.describe ActivityPub::ProcessCollectionService, type: :service do 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) + allow_any_instance_of(ActivityPub::LinkedDataSignature).to receive(:verify_actor!).and_return(nil) expect(ActivityPub::Activity).to_not receive(:factory) subject.call(json, forwarder) @@ -98,8 +100,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 @@ -123,7 +135,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', diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index c8aa56def1e..9d91f31cc5c 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' def poll_option_json(name, votes) @@ -39,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 @@ -62,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 @@ -85,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 @@ -132,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 @@ -184,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 @@ -212,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 @@ -231,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 @@ -255,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 @@ -267,9 +269,9 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end end - context 'originally without tags' do + context 'when originally without tags' do before do - subject.call(status, json) + subject.call(status, json, json) end it 'updates tags' do @@ -277,7 +279,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end end - context 'originally with tags' do + context 'when originally with tags' do let(:tags) { [Fabricate(:tag, name: 'test'), Fabricate(:tag, name: 'foo')] } let(:payload) do @@ -295,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 @@ -303,9 +305,9 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end end - context 'originally without mentions' do + context 'when originally without mentions' do before do - subject.call(status, json) + subject.call(status, json, json) end it 'updates mentions' do @@ -313,11 +315,11 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end end - context 'originally with mentions' do + context 'when originally with mentions' do let(:mentions) { [alice, bob] } before do - subject.call(status, json) + subject.call(status, json, json) end it 'updates mentions' do @@ -325,10 +327,10 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end end - context 'originally without media attachments' 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 @@ -360,7 +362,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end end - context 'originally with media attachments' do + context 'when originally with media attachments' do let(:media_attachments) { [Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png'), Fabricate(:media_attachment, remote_url: 'https://example.com/unused.png')] } let(:payload) do @@ -378,7 +380,7 @@ 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 @@ -402,11 +404,11 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end end - context 'originally with a poll' do + context 'when originally with a poll' 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 @@ -418,7 +420,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end end - context 'originally without a poll' do + context 'when originally without a poll' do let(:payload) do { '@context': 'https://www.w3.org/ns/activitystreams', @@ -436,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 @@ -452,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/activitypub/synchronize_followers_service_spec.rb b/spec/services/activitypub/synchronize_followers_service_spec.rb index 0e829a3028f..c9a513e24b7 100644 --- a/spec/services/activitypub/synchronize_followers_service_spec.rb +++ b/spec/services/activitypub/synchronize_followers_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do 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 9cca82bffa5..05af125997c 100644 --- a/spec/services/after_block_domain_from_account_service_spec.rb +++ b/spec/services/after_block_domain_from_account_service_spec.rb @@ -1,13 +1,14 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe AfterBlockDomainFromAccountService, type: :service do - subject { AfterBlockDomainFromAccountService.new } + subject { described_class.new } let!(:wolf) { Fabricate(:account, username: 'wolf', domain: 'evil.org', inbox_url: 'https://evil.org/inbox', protocol: :activitypub) } let!(:alice) { Fabricate(:account, username: 'alice') } before do - stub_jsonld_contexts! allow(ActivityPub::DeliveryWorker).to receive(:perform_async) end diff --git a/spec/services/after_block_service_spec.rb b/spec/services/after_block_service_spec.rb index 337766d066a..d81bba1d8d9 100644 --- a/spec/services/after_block_service_spec.rb +++ b/spec/services/after_block_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe AfterBlockService, type: :service do diff --git a/spec/services/app_sign_up_service_spec.rb b/spec/services/app_sign_up_service_spec.rb index 10da07dcfb6..2532304964c 100644 --- a/spec/services/app_sign_up_service_spec.rb +++ b/spec/services/app_sign_up_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe AppSignUpService, type: :service do diff --git a/spec/services/authorize_follow_service_spec.rb b/spec/services/authorize_follow_service_spec.rb index 8f8e44ec761..d07645ab6b1 100644 --- a/spec/services/authorize_follow_service_spec.rb +++ b/spec/services/authorize_follow_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe AuthorizeFollowService, type: :service do - subject { AuthorizeFollowService.new } + subject { described_class.new } let(:sender) { Fabricate(:account, username: 'alice') } diff --git a/spec/services/backup_service_spec.rb b/spec/services/backup_service_spec.rb new file mode 100644 index 00000000000..806ba18323e --- /dev/null +++ b/spec/services/backup_service_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe BackupService, type: :service do + subject(:service_call) { described_class.new.call(backup) } + + let!(:user) { Fabricate(:user) } + let!(:attachment) { Fabricate(:media_attachment, account: user.account) } + let!(:status) { Fabricate(:status, account: user.account, text: 'Hello', visibility: :public, media_attachments: [attachment]) } + let!(:private_status) { Fabricate(:status, account: user.account, text: 'secret', visibility: :private) } + let!(:favourite) { Fabricate(:favourite, account: user.account) } + let!(:bookmark) { Fabricate(:bookmark, account: user.account) } + let!(:backup) { Fabricate(:backup, user: user) } + + def read_zip_file(backup, filename) + file = Paperclip.io_adapters.for(backup.dump) + Zip::File.open(file) do |zipfile| + entry = zipfile.glob(filename).first + return entry.get_input_stream.read + end + end + + context 'when the user has an avatar and header' do + before do + user.account.update!(avatar: attachment_fixture('avatar.gif')) + user.account.update!(header: attachment_fixture('emojo.png')) + end + + it 'stores them as expected' do + service_call + + json = export_json(:actor) + avatar_path = json.dig('icon', 'url') + header_path = json.dig('image', 'url') + + expect(avatar_path).to_not be_nil + expect(header_path).to_not be_nil + + expect(read_zip_file(backup, avatar_path)).to be_present + expect(read_zip_file(backup, header_path)).to be_present + end + end + + it 'marks the backup as processed and exports files' do + expect { service_call }.to process_backup + + expect_outbox_export + expect_likes_export + expect_bookmarks_export + end + + def process_backup + change(backup, :processed).from(false).to(true) + end + + def expect_outbox_export + json = export_json(:outbox) + + aggregate_failures do + expect(json['@context']).to_not be_nil + expect(json['type']).to eq 'OrderedCollection' + expect(json['totalItems']).to eq 2 + expect(json['orderedItems'][0]['@context']).to be_nil + expect(json['orderedItems'][0]).to include_create_item(status) + expect(json['orderedItems'][1]).to include_create_item(private_status) + end + end + + def expect_likes_export + json = export_json(:likes) + + aggregate_failures do + expect(json['type']).to eq 'OrderedCollection' + expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(favourite.status)] + end + end + + def expect_bookmarks_export + json = export_json(:bookmarks) + + aggregate_failures do + expect(json['type']).to eq 'OrderedCollection' + expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(bookmark.status)] + end + end + + def export_json(type) + Oj.load(read_zip_file(backup, "#{type}.json")) + end + + def include_create_item(status) + include({ + 'type' => 'Create', + 'object' => include({ + 'id' => ActivityPub::TagManager.instance.uri_for(status), + 'content' => "

#{status.text}

", + }), + }) + end +end diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index 920edeb13e5..8201c9d51f9 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -1,15 +1,17 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe BatchedRemoveStatusService, type: :service do - subject { BatchedRemoveStatusService.new } + subject { described_class.new } let!(:alice) { Fabricate(:account) } let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } let!(:jeff) { Fabricate(:account) } let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } - let(:status1) { PostStatusService.new.call(alice, text: 'Hello @bob@example.com') } - let(:status2) { PostStatusService.new.call(alice, text: 'Another status') } + let(:status_alice_hello) { PostStatusService.new.call(alice, text: 'Hello @bob@example.com') } + let(:status_alice_other) { PostStatusService.new.call(alice, text: 'Another status') } before do allow(redis).to receive_messages(publish: nil) @@ -20,23 +22,23 @@ RSpec.describe BatchedRemoveStatusService, type: :service do jeff.follow!(alice) hank.follow!(alice) - status1 - status2 + status_alice_hello + status_alice_other - subject.call([status1, status2]) + subject.call([status_alice_hello, status_alice_other]) end it 'removes statuses' do - expect { Status.find(status1.id) }.to raise_error ActiveRecord::RecordNotFound - expect { Status.find(status2.id) }.to raise_error ActiveRecord::RecordNotFound + expect { Status.find(status_alice_hello.id) }.to raise_error ActiveRecord::RecordNotFound + expect { Status.find(status_alice_other.id) }.to raise_error ActiveRecord::RecordNotFound end it 'removes statuses from author\'s home feed' do - expect(HomeFeed.new(alice).get(10)).to_not include([status1.id, status2.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([status1.id, status2.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 56b3a5ad1c4..36dce9d1963 100644 --- a/spec/services/block_domain_service_spec.rb +++ b/spec/services/block_domain_service_spec.rb @@ -1,12 +1,14 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe BlockDomainService, type: :service do - subject { BlockDomainService.new } + subject { described_class.new } let!(:bad_account) { Fabricate(:account, username: 'badguy666', domain: 'evil.org') } - let!(:bad_status1) { Fabricate(:status, account: bad_account, text: 'You suck') } - let!(:bad_status2) { Fabricate(:status, account: bad_account, text: 'Hahaha') } - let!(:bad_attachment) { Fabricate(:media_attachment, account: bad_account, status: bad_status2, file: attachment_fixture('attachment.jpg')) } + let!(:bad_status_plain) { Fabricate(:status, account: bad_account, text: 'You suck') } + let!(:bad_status_with_attachment) { Fabricate(:status, account: bad_account, text: 'Hahaha') } + let!(:bad_attachment) { Fabricate(:media_attachment, account: bad_account, status: bad_status_with_attachment, file: attachment_fixture('attachment.jpg')) } let!(:already_banned_account) { Fabricate(:account, username: 'badguy', domain: 'evil.org', suspended: true, silenced: true) } describe 'for a suspension' do @@ -35,8 +37,8 @@ RSpec.describe BlockDomainService, type: :service do end it 'removes the remote accounts\'s statuses and media attachments' do - expect { bad_status1.reload }.to raise_exception ActiveRecord::RecordNotFound - expect { bad_status2.reload }.to raise_exception ActiveRecord::RecordNotFound + expect { bad_status_plain.reload }.to raise_exception ActiveRecord::RecordNotFound + expect { bad_status_with_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound expect { bad_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound end end @@ -67,8 +69,8 @@ RSpec.describe BlockDomainService, type: :service do end it 'leaves the domains status and attachments, but clears media' do - expect { bad_status1.reload }.to_not raise_error - expect { bad_status2.reload }.to_not raise_error + 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 expect(bad_attachment.file.exists?).to be false end diff --git a/spec/services/block_service_spec.rb b/spec/services/block_service_spec.rb index 049644dbc04..5f7c2e8da08 100644 --- a/spec/services/block_service_spec.rb +++ b/spec/services/block_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe BlockService, type: :service do - subject { BlockService.new } + subject { described_class.new } let(:sender) { Fabricate(:account, username: 'alice') } diff --git a/spec/services/bootstrap_timeline_service_spec.rb b/spec/services/bootstrap_timeline_service_spec.rb index 149f6e6dfcd..721a0337fd3 100644 --- a/spec/services/bootstrap_timeline_service_spec.rb +++ b/spec/services/bootstrap_timeline_service_spec.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe BootstrapTimelineService, type: :service do - subject { BootstrapTimelineService.new } + subject { described_class.new } context 'when the new user has registered from an invite' do - let(:service) { double } + let(:service) { instance_double(FollowService) } let(:autofollow) { false } let(:inviter) { Fabricate(:user, confirmed_at: 2.days.ago) } let(:invite) { Fabricate(:invite, user: inviter, max_uses: nil, expires_at: 1.hour.from_now, autofollow: autofollow) } diff --git a/spec/services/bulk_import_row_service_spec.rb b/spec/services/bulk_import_row_service_spec.rb new file mode 100644 index 00000000000..a77acc07321 --- /dev/null +++ b/spec/services/bulk_import_row_service_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe BulkImportRowService do + subject { described_class.new } + + let(:account) { Fabricate(:account) } + let(:import) { Fabricate(:bulk_import, account: account, type: import_type) } + let(:import_row) { Fabricate(:bulk_import_row, bulk_import: import, data: data) } + + describe '#call' do + context 'when importing a follow' do + let(:import_type) { 'following' } + let(:target_account) { Fabricate(:account) } + let(:service_double) { instance_double(FollowService, call: nil) } + let(:data) do + { 'acct' => target_account.acct } + end + + before do + allow(FollowService).to receive(:new).and_return(service_double) + end + + it 'calls FollowService with the expected arguments and returns true' do + expect(subject.call(import_row)).to be true + + expect(service_double).to have_received(:call).with(account, target_account, { reblogs: nil, notify: nil, languages: nil }) + end + end + + context 'when importing a block' do + let(:import_type) { 'blocking' } + let(:target_account) { Fabricate(:account) } + let(:service_double) { instance_double(BlockService, call: nil) } + let(:data) do + { 'acct' => target_account.acct } + end + + before do + allow(BlockService).to receive(:new).and_return(service_double) + end + + it 'calls BlockService with the expected arguments and returns true' do + expect(subject.call(import_row)).to be true + + expect(service_double).to have_received(:call).with(account, target_account) + end + end + + context 'when importing a mute' do + let(:import_type) { 'muting' } + let(:target_account) { Fabricate(:account) } + let(:service_double) { instance_double(MuteService, call: nil) } + let(:data) do + { 'acct' => target_account.acct } + end + + before do + allow(MuteService).to receive(:new).and_return(service_double) + end + + it 'calls MuteService with the expected arguments and returns true' do + expect(subject.call(import_row)).to be true + + expect(service_double).to have_received(:call).with(account, target_account, { notifications: nil }) + end + end + + context 'when importing a bookmark' do + let(:import_type) { 'bookmarks' } + let(:data) do + { 'uri' => ActivityPub::TagManager.instance.uri_for(target_status) } + end + + context 'when the status is public' do + let(:target_status) { Fabricate(:status) } + + it 'bookmarks the status and returns true' do + expect(subject.call(import_row)).to be true + expect(account.bookmarked?(target_status)).to be true + end + end + + context 'when the status is not accessible to the user' do + let(:target_status) { Fabricate(:status, visibility: :direct) } + + it 'does not bookmark the status and returns false' do + expect(subject.call(import_row)).to be false + expect(account.bookmarked?(target_status)).to be false + end + end + end + + context 'when importing a list row' do + let(:import_type) { 'lists' } + let(:target_account) { Fabricate(:account) } + let(:data) do + { 'acct' => target_account.acct, 'list_name' => 'my list' } + end + + shared_examples 'common behavior' do + context 'when the target account is already followed' do + before do + account.follow!(target_account) + end + + it 'returns true' do + expect(subject.call(import_row)).to be true + end + + it 'adds the target account to the list' do + expect { subject.call(import_row) }.to change { ListAccount.joins(:list).exists?(account_id: target_account.id, list: { title: 'my list' }) }.from(false).to(true) + end + end + + context 'when the user already requested to follow the target account' do + before do + account.request_follow!(target_account) + end + + it 'returns true' do + expect(subject.call(import_row)).to be true + end + + it 'adds the target account to the list' do + expect { subject.call(import_row) }.to change { ListAccount.joins(:list).exists?(account_id: target_account.id, list: { title: 'my list' }) }.from(false).to(true) + end + end + + context 'when the target account is neither followed nor requested' do + it 'returns true' do + expect(subject.call(import_row)).to be true + end + + it 'adds the target account to the list' do + expect { subject.call(import_row) }.to change { ListAccount.joins(:list).exists?(account_id: target_account.id, list: { title: 'my list' }) }.from(false).to(true) + end + end + + context 'when the target account is the user themself' do + let(:target_account) { account } + + it 'returns true' do + expect(subject.call(import_row)).to be true + end + + it 'adds the target account to the list' do + expect { subject.call(import_row) }.to change { ListAccount.joins(:list).exists?(account_id: target_account.id, list: { title: 'my list' }) }.from(false).to(true) + end + end + end + + context 'when the list does not exist yet' do + include_examples 'common behavior' + end + + context 'when the list exists' do + before do + Fabricate(:list, account: account, title: 'my list') + 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 +end diff --git a/spec/services/bulk_import_service_spec.rb b/spec/services/bulk_import_service_spec.rb new file mode 100644 index 00000000000..281b642ea41 --- /dev/null +++ b/spec/services/bulk_import_service_spec.rb @@ -0,0 +1,417 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe BulkImportService do + subject { described_class.new } + + let(:account) { Fabricate(:account) } + let(:import) { Fabricate(:bulk_import, account: account, type: import_type, overwrite: overwrite, state: :in_progress, imported_items: 0, processed_items: 0) } + + before do + import.update(total_items: import.rows.count) + end + + describe '#call' do + around do |example| + Sidekiq::Testing.fake! do + example.run + Sidekiq::Worker.clear_all + end + end + + context 'when importing follows' do + let(:import_type) { 'following' } + let(:overwrite) { false } + + let!(:rows) do + [ + { 'acct' => 'user@foo.bar' }, + { 'acct' => 'unknown@unknown.bar' }, + ].map { |data| import.rows.create!(data: data) } + end + + before do + account.follow!(Fabricate(:account)) + end + + it 'does not immediately change who the account follows' do + expect { subject.call(import) }.to_not(change { account.reload.active_relationships.to_a }) + end + + it 'enqueues workers for the expected rows' do + subject.call(import) + expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows.map(&:id)) + end + + it 'requests to follow all the listed users once the workers have run' do + subject.call(import) + + resolve_account_service_double = instance_double(ResolveAccountService) + allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) + allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } + allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } + + 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') + end + end + + context 'when importing follows with overwrite' do + let(:import_type) { 'following' } + let(:overwrite) { true } + + let!(:followed) { Fabricate(:account, username: 'followed', domain: 'foo.bar', protocol: :activitypub) } + let!(:to_be_unfollowed) { Fabricate(:account, username: 'to_be_unfollowed', domain: 'foo.bar', protocol: :activitypub) } + + let!(:rows) do + [ + { 'acct' => 'followed@foo.bar', 'show_reblogs' => false, 'notify' => true, 'languages' => ['en'] }, + { 'acct' => 'user@foo.bar' }, + { 'acct' => 'unknown@unknown.bar' }, + ].map { |data| import.rows.create!(data: data) } + end + + before do + account.follow!(followed, reblogs: true, notify: false) + account.follow!(to_be_unfollowed) + end + + it 'unfollows user not present on list' do + subject.call(import) + expect(account.following?(to_be_unfollowed)).to be false + end + + it 'updates the existing follow relationship as expected' do + expect { subject.call(import) }.to change { Follow.where(account: account, target_account: followed).pick(:show_reblogs, :notify, :languages) }.from([true, false, nil]).to([false, true, ['en']]) + end + + it 'enqueues workers for the expected rows' do + subject.call(import) + expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows[1..].map(&:id)) + end + + it 'requests to follow all the expected users once the workers have run' do + subject.call(import) + + resolve_account_service_double = instance_double(ResolveAccountService) + allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) + allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } + allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } + + 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') + end + end + + context 'when importing blocks' do + let(:import_type) { 'blocking' } + let(:overwrite) { false } + + let!(:rows) do + [ + { 'acct' => 'user@foo.bar' }, + { 'acct' => 'unknown@unknown.bar' }, + ].map { |data| import.rows.create!(data: data) } + end + + before do + account.block!(Fabricate(:account, username: 'already_blocked', domain: 'remote.org')) + end + + it 'does not immediately change who the account blocks' do + expect { subject.call(import) }.to_not(change { account.reload.blocking.to_a }) + end + + it 'enqueues workers for the expected rows' do + subject.call(import) + expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows.map(&:id)) + end + + it 'blocks all the listed users once the workers have run' do + subject.call(import) + + resolve_account_service_double = instance_double(ResolveAccountService) + allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) + allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } + allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } + + Import::RowWorker.drain + + expect(account.blocking.map(&:acct)).to contain_exactly('already_blocked@remote.org', 'user@foo.bar', 'unknown@unknown.bar') + end + end + + context 'when importing blocks with overwrite' do + let(:import_type) { 'blocking' } + let(:overwrite) { true } + + let!(:blocked) { Fabricate(:account, username: 'blocked', domain: 'foo.bar', protocol: :activitypub) } + let!(:to_be_unblocked) { Fabricate(:account, username: 'to_be_unblocked', domain: 'foo.bar', protocol: :activitypub) } + + let!(:rows) do + [ + { 'acct' => 'blocked@foo.bar' }, + { 'acct' => 'user@foo.bar' }, + { 'acct' => 'unknown@unknown.bar' }, + ].map { |data| import.rows.create!(data: data) } + end + + before do + account.block!(blocked) + account.block!(to_be_unblocked) + end + + it 'unblocks user not present on list' do + subject.call(import) + expect(account.blocking?(to_be_unblocked)).to be false + end + + it 'enqueues workers for the expected rows' do + subject.call(import) + expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows[1..].map(&:id)) + end + + it 'requests to follow all the expected users once the workers have run' do + subject.call(import) + + resolve_account_service_double = instance_double(ResolveAccountService) + allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) + allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } + allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } + + Import::RowWorker.drain + + expect(account.blocking.map(&:acct)).to contain_exactly('blocked@foo.bar', 'user@foo.bar', 'unknown@unknown.bar') + end + end + + context 'when importing mutes' do + let(:import_type) { 'muting' } + let(:overwrite) { false } + + let!(:rows) do + [ + { 'acct' => 'user@foo.bar' }, + { 'acct' => 'unknown@unknown.bar' }, + ].map { |data| import.rows.create!(data: data) } + end + + before do + account.mute!(Fabricate(:account, username: 'already_muted', domain: 'remote.org')) + end + + it 'does not immediately change who the account blocks' do + expect { subject.call(import) }.to_not(change { account.reload.muting.to_a }) + end + + it 'enqueues workers for the expected rows' do + subject.call(import) + expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows.map(&:id)) + end + + it 'mutes all the listed users once the workers have run' do + subject.call(import) + + resolve_account_service_double = instance_double(ResolveAccountService) + allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) + allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } + allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } + + Import::RowWorker.drain + + expect(account.muting.map(&:acct)).to contain_exactly('already_muted@remote.org', 'user@foo.bar', 'unknown@unknown.bar') + end + end + + context 'when importing mutes with overwrite' do + let(:import_type) { 'muting' } + let(:overwrite) { true } + + let!(:muted) { Fabricate(:account, username: 'muted', domain: 'foo.bar', protocol: :activitypub) } + let!(:to_be_unmuted) { Fabricate(:account, username: 'to_be_unmuted', domain: 'foo.bar', protocol: :activitypub) } + + let!(:rows) do + [ + { 'acct' => 'muted@foo.bar', 'hide_notifications' => true }, + { 'acct' => 'user@foo.bar' }, + { 'acct' => 'unknown@unknown.bar' }, + ].map { |data| import.rows.create!(data: data) } + end + + before do + account.mute!(muted, notifications: false) + account.mute!(to_be_unmuted) + end + + it 'updates the existing mute as expected' do + expect { subject.call(import) }.to change { Mute.where(account: account, target_account: muted).pick(:hide_notifications) }.from(false).to(true) + end + + it 'unblocks user not present on list' do + subject.call(import) + expect(account.muting?(to_be_unmuted)).to be false + end + + it 'enqueues workers for the expected rows' do + subject.call(import) + expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows[1..].map(&:id)) + end + + it 'requests to follow all the expected users once the workers have run' do + subject.call(import) + + resolve_account_service_double = instance_double(ResolveAccountService) + allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) + allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } + allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } + + Import::RowWorker.drain + + expect(account.muting.map(&:acct)).to contain_exactly('muted@foo.bar', 'user@foo.bar', 'unknown@unknown.bar') + end + end + + context 'when importing domain blocks' do + let(:import_type) { 'domain_blocking' } + let(:overwrite) { false } + + let!(:rows) do + [ + { 'domain' => 'blocked.com' }, + { 'domain' => 'to_block.com' }, + ].map { |data| import.rows.create!(data: data) } + end + + before do + account.block_domain!('alreadyblocked.com') + account.block_domain!('blocked.com') + end + + it 'blocks all the new domains' do + subject.call(import) + expect(account.domain_blocks.pluck(:domain)).to contain_exactly('alreadyblocked.com', 'blocked.com', 'to_block.com') + end + + it 'marks the import as finished' do + subject.call(import) + expect(import.reload.finished?).to be true + end + end + + context 'when importing domain blocks with overwrite' do + let(:import_type) { 'domain_blocking' } + let(:overwrite) { true } + + let!(:rows) do + [ + { 'domain' => 'blocked.com' }, + { 'domain' => 'to_block.com' }, + ].map { |data| import.rows.create!(data: data) } + end + + before do + account.block_domain!('alreadyblocked.com') + account.block_domain!('blocked.com') + end + + it 'blocks all the new domains' do + subject.call(import) + expect(account.domain_blocks.pluck(:domain)).to contain_exactly('blocked.com', 'to_block.com') + end + + it 'marks the import as finished' do + subject.call(import) + expect(import.reload.finished?).to be true + end + end + + context 'when importing bookmarks' do + let(:import_type) { 'bookmarks' } + let(:overwrite) { false } + + let!(:already_bookmarked) { Fabricate(:status, uri: 'https://already.bookmarked/1') } + let!(:status) { Fabricate(:status, uri: 'https://foo.bar/posts/1') } + let!(:inaccessible_status) { Fabricate(:status, uri: 'https://foo.bar/posts/inaccessible', visibility: :direct) } + let!(:bookmarked) { Fabricate(:status, uri: 'https://foo.bar/posts/already-bookmarked') } + + let!(:rows) do + [ + { 'uri' => status.uri }, + { 'uri' => inaccessible_status.uri }, + { 'uri' => bookmarked.uri }, + { 'uri' => 'https://domain.unknown/foo' }, + { 'uri' => 'https://domain.unknown/private' }, + ].map { |data| import.rows.create!(data: data) } + end + + before do + account.bookmarks.create!(status: already_bookmarked) + account.bookmarks.create!(status: bookmarked) + end + + it 'enqueues workers for the expected rows' do + subject.call(import) + expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows.map(&:id)) + end + + it 'updates the bookmarks as expected once the workers have run' do + subject.call(import) + + service_double = instance_double(ActivityPub::FetchRemoteStatusService) + allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double) + allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') } + allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) } + + Import::RowWorker.drain + + expect(account.bookmarks.map(&:status).map(&:uri)).to contain_exactly(already_bookmarked.uri, status.uri, bookmarked.uri, 'https://domain.unknown/foo') + end + end + + context 'when importing bookmarks with overwrite' do + let(:import_type) { 'bookmarks' } + let(:overwrite) { true } + + let!(:already_bookmarked) { Fabricate(:status, uri: 'https://already.bookmarked/1') } + let!(:status) { Fabricate(:status, uri: 'https://foo.bar/posts/1') } + let!(:inaccessible_status) { Fabricate(:status, uri: 'https://foo.bar/posts/inaccessible', visibility: :direct) } + let!(:bookmarked) { Fabricate(:status, uri: 'https://foo.bar/posts/already-bookmarked') } + + let!(:rows) do + [ + { 'uri' => status.uri }, + { 'uri' => inaccessible_status.uri }, + { 'uri' => bookmarked.uri }, + { 'uri' => 'https://domain.unknown/foo' }, + { 'uri' => 'https://domain.unknown/private' }, + ].map { |data| import.rows.create!(data: data) } + end + + before do + account.bookmarks.create!(status: already_bookmarked) + account.bookmarks.create!(status: bookmarked) + end + + it 'enqueues workers for the expected rows' do + subject.call(import) + expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows.map(&:id)) + end + + it 'updates the bookmarks as expected once the workers have run' do + subject.call(import) + + service_double = instance_double(ActivityPub::FetchRemoteStatusService) + allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double) + allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') } + allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) } + + Import::RowWorker.drain + + expect(account.bookmarks.map(&:status).map(&:uri)).to contain_exactly(status.uri, bookmarked.uri, 'https://domain.unknown/foo') + end + end + end +end diff --git a/spec/services/clear_domain_media_service_spec.rb b/spec/services/clear_domain_media_service_spec.rb index 993ba789ea3..9766e62de8c 100644 --- a/spec/services/clear_domain_media_service_spec.rb +++ b/spec/services/clear_domain_media_service_spec.rb @@ -1,12 +1,14 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ClearDomainMediaService, type: :service do - subject { ClearDomainMediaService.new } + subject { described_class.new } let!(:bad_account) { Fabricate(:account, username: 'badguy666', domain: 'evil.org') } - let!(:bad_status1) { Fabricate(:status, account: bad_account, text: 'You suck') } - let!(:bad_status2) { Fabricate(:status, account: bad_account, text: 'Hahaha') } - let!(:bad_attachment) { Fabricate(:media_attachment, account: bad_account, status: bad_status2, file: attachment_fixture('attachment.jpg')) } + let!(:bad_status_plain) { Fabricate(:status, account: bad_account, text: 'You suck') } + let!(:bad_status_with_attachment) { Fabricate(:status, account: bad_account, text: 'Hahaha') } + let!(:bad_attachment) { Fabricate(:media_attachment, account: bad_account, status: bad_status_with_attachment, file: attachment_fixture('attachment.jpg')) } describe 'for a silence with reject media' do before do @@ -14,8 +16,8 @@ RSpec.describe ClearDomainMediaService, type: :service do end it 'leaves the domains status and attachments, but clears media' do - expect { bad_status1.reload }.to_not raise_error - expect { bad_status2.reload }.to_not raise_error + 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 expect(bad_attachment.file.exists?).to be false end diff --git a/spec/services/delete_account_service_spec.rb b/spec/services/delete_account_service_spec.rb index e5bfdd679fd..68ab491e4ea 100644 --- a/spec/services/delete_account_service_spec.rb +++ b/spec/services/delete_account_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe DeleteAccountService, type: :service do @@ -63,8 +65,8 @@ RSpec.describe DeleteAccountService, type: :service do stub_request(:post, 'https://bob.com/inbox').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) } @@ -85,12 +87,34 @@ RSpec.describe DeleteAccountService, type: :service do end include_examples 'common behavior' do - let!(:account) { Fabricate(:account, inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } + 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(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), + }), + }) + )).to have_been_made.once + + expect(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, + }), + }) + )).to have_been_made.once 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 d09750dd237..3b554f9ea3b 100644 --- a/spec/services/fan_out_on_write_service_spec.rb +++ b/spec/services/fan_out_on_write_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe FanOutOnWriteService, type: :service do diff --git a/spec/services/favourite_service_spec.rb b/spec/services/favourite_service_spec.rb index 4f621200a23..782c235c418 100644 --- a/spec/services/favourite_service_spec.rb +++ b/spec/services/favourite_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe FavouriteService, type: :service do - subject { FavouriteService.new } + subject { described_class.new } let(:sender) { Fabricate(:account, username: 'alice') } diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb index 458473c39e8..f44cbb750c7 100644 --- a/spec/services/fetch_link_card_service_spec.rb +++ b/spec/services/fetch_link_card_service_spec.rb @@ -1,97 +1,253 @@ +# frozen_string_literal: true + 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-').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 'in a local status' do - context do + context 'with a local status' 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 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 + it 'decodes the HTML' do expect(status.preview_cards.first.title).to eq('SJISのページ') end end - context 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 + it 'decodes the HTML despite the wrong charset header' do expect(status.preview_cards.first.title).to eq('SJISのページ') end end - context 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 + it 'decodes the HTML' do expect(status.preview_cards.first.title).to eq('Московя начинаетъ только въ XVI ст. привлекать внимане иностранцевъ.') end end - context 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 + it 'decodes the HTML' do expect(status.preview_cards.first.title).to eq('сэмпл текст') end end - context 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 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 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 of a page with oEmbed support' do + let(:html) { 'Hello world' } + let(:status) { Fabricate(:status, text: 'http://example.com/html') } + + 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 'in 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 ') } + context 'with a remote status' do + 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_oembed_service_spec.rb b/spec/services/fetch_oembed_service_spec.rb index 8a0b4922239..777cbae3fbd 100644 --- a/spec/services/fetch_oembed_service_spec.rb +++ b/spec/services/fetch_oembed_service_spec.rb @@ -39,7 +39,7 @@ describe FetchOEmbedService, type: :service do end end - context 'Both of JSON and XML provider are discoverable' do + context 'when both of JSON and XML provider are discoverable' do before do stub_request(:get, 'https://host.test/oembed.html').to_return( status: 200, @@ -66,7 +66,7 @@ describe FetchOEmbedService, type: :service do end end - context 'JSON provider is discoverable while XML provider is not' do + context 'when JSON provider is discoverable while XML provider is not' do before do stub_request(:get, 'https://host.test/oembed.html').to_return( status: 200, @@ -87,7 +87,7 @@ describe FetchOEmbedService, type: :service do end end - context 'XML provider is discoverable while JSON provider is not' do + context 'when XML provider is discoverable while JSON provider is not' do before do stub_request(:get, 'https://host.test/oembed.html').to_return( status: 200, @@ -108,7 +108,7 @@ describe FetchOEmbedService, type: :service do end end - context 'Invalid XML provider is discoverable while JSON provider is not' do + context 'with Invalid XML provider is discoverable while JSON provider is not' do before do stub_request(:get, 'https://host.test/oembed.html').to_return( status: 200, @@ -122,7 +122,7 @@ describe FetchOEmbedService, type: :service do end end - context 'Neither of JSON and XML provider is discoverable' do + context 'with neither of JSON and XML provider is discoverable' do before do stub_request(:get, 'https://host.test/oembed.html').to_return( status: 200, @@ -136,7 +136,7 @@ describe FetchOEmbedService, type: :service do end end - context 'Empty JSON provider is discoverable' do + context 'when empty JSON provider is discoverable' do before do stub_request(:get, 'https://host.test/oembed.html').to_return( status: 200, diff --git a/spec/services/fetch_remote_status_service_spec.rb b/spec/services/fetch_remote_status_service_spec.rb index ace520b8fca..798740c9b31 100644 --- a/spec/services/fetch_remote_status_service_spec.rb +++ b/spec/services/fetch_remote_status_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe FetchRemoteStatusService, type: :service do @@ -14,7 +16,7 @@ RSpec.describe FetchRemoteStatusService, type: :service do } end - context 'protocol is :activitypub' do + context 'when protocol is :activitypub' do subject { described_class.new.call(note[:id], prefetched_body: prefetched_body) } let(:prefetched_body) { Oj.dump(note) } diff --git a/spec/services/fetch_resource_service_spec.rb b/spec/services/fetch_resource_service_spec.rb index 226c98d70a4..0f1068471f8 100644 --- a/spec/services/fetch_resource_service_spec.rb +++ b/spec/services/fetch_resource_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe FetchResourceService, type: :service do @@ -22,7 +24,7 @@ RSpec.describe FetchResourceService, type: :service do context 'when OpenSSL::SSL::SSLError is raised' do before do - request = double + request = instance_double(Request) allow(Request).to receive(:new).and_return(request) allow(request).to receive(:add_headers) allow(request).to receive(:on_behalf_of) @@ -34,7 +36,7 @@ RSpec.describe FetchResourceService, type: :service do context 'when HTTP::ConnectionError is raised' do before do - request = double + request = instance_double(Request) allow(Request).to receive(:new).and_return(request) allow(request).to receive(:add_headers) allow(request).to receive(:on_behalf_of) diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb index f95d5944096..c2ad0d71739 100644 --- a/spec/services/follow_service_spec.rb +++ b/spec/services/follow_service_spec.rb @@ -1,11 +1,13 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe FollowService, type: :service do - subject { FollowService.new } + subject { described_class.new } let(:sender) { Fabricate(:account, username: 'alice') } - context 'local account' do + context 'when local account' do describe 'locked account' do let(:bob) { Fabricate(:account, locked: true, username: 'bob') } @@ -136,7 +138,7 @@ RSpec.describe FollowService, type: :service do end end - context 'remote ActivityPub account' do + context 'when remote ActivityPub account' do let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } before do diff --git a/spec/services/import_service_spec.rb b/spec/services/import_service_spec.rb index 4a517fb933a..1904ac8dc91 100644 --- a/spec/services/import_service_spec.rb +++ b/spec/services/import_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ImportService, type: :service do @@ -11,8 +13,8 @@ RSpec.describe ImportService, type: :service do stub_request(:post, 'https://example.com/inbox').to_return(status: 200) end - context 'import old-style list of muted users' do - subject { ImportService.new } + context 'when importing old-style list of muted users' do + subject { described_class.new } let(:csv) { attachment_fixture('mute-imports.txt') } @@ -49,8 +51,8 @@ RSpec.describe ImportService, type: :service do end end - context 'import new-style list of muted users' do - subject { ImportService.new } + context 'when importing new-style list of muted users' do + subject { described_class.new } let(:csv) { attachment_fixture('new-mute-imports.txt') } @@ -90,8 +92,8 @@ RSpec.describe ImportService, type: :service do end end - context 'import old-style list of followed users' do - subject { ImportService.new } + context 'when importing old-style list of followed users' do + subject { described_class.new } let(:csv) { attachment_fixture('mute-imports.txt') } @@ -132,8 +134,8 @@ RSpec.describe ImportService, type: :service do end end - context 'import new-style list of followed users' do - subject { ImportService.new } + context 'when importing new-style list of followed users' do + subject { described_class.new } let(:csv) { attachment_fixture('new-following-imports.txt') } @@ -179,8 +181,8 @@ RSpec.describe ImportService, type: :service do # Based on the bug report 20571 where UTF-8 encoded domains were rejecting import of their users # # https://github.com/mastodon/mastodon/issues/20571 - context 'utf-8 encoded domains' do - subject { ImportService.new } + context 'with a utf-8 encoded domains' do + subject { described_class.new } let!(:nare) { Fabricate(:account, username: 'nare', domain: 'թութ.հայ', locked: false, protocol: :activitypub, inbox_url: 'https://թութ.հայ/inbox') } let(:csv) { attachment_fixture('utf8-followers.txt') } @@ -198,8 +200,8 @@ RSpec.describe ImportService, type: :service do end end - context 'import bookmarks' do - subject { ImportService.new } + context 'when importing bookmarks' do + subject { described_class.new } let(:csv) { attachment_fixture('bookmark-imports.txt') } let(:local_account) { Fabricate(:account, username: 'foo', domain: '') } @@ -217,7 +219,7 @@ RSpec.describe ImportService, type: :service do end before do - service = double + service = instance_double(ActivityPub::FetchRemoteStatusService) allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service) allow(service).to receive(:call).with('https://unknown-remote.com/users/bar/statuses/1') do Fabricate(:status, uri: 'https://unknown-remote.com/users/bar/statuses/1') diff --git a/spec/services/mute_service_spec.rb b/spec/services/mute_service_spec.rb index 57d8c41dec3..50f74ff277f 100644 --- a/spec/services/mute_service_spec.rb +++ b/spec/services/mute_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe MuteService, type: :service do diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index 294c31b0445..8fcb5865804 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe NotifyService, type: :service do @@ -47,22 +49,23 @@ RSpec.describe NotifyService, type: :service do expect { subject }.to_not change(Notification, :count) end - context 'for direct messages' do + context 'with direct messages' do let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct)) } let(:type) { :mention } before do - user.settings.interactions = user.settings.interactions.merge('must_be_following_dm' => enabled) + user.settings.update('interactions.must_be_following_dm': enabled) + user.save end - context 'if recipient is supposed to be following sender' do + context 'when recipient is supposed to be following sender' do let(:enabled) { true } it 'does not notify' do expect { subject }.to_not change(Notification, :count) end - context 'if the message chain is initiated by recipient, but is not direct message' 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)) } @@ -72,7 +75,7 @@ RSpec.describe NotifyService, type: :service do end end - context 'if 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 + 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) } @@ -83,7 +86,7 @@ RSpec.describe NotifyService, type: :service do end end - context 'if the message chain is initiated by the recipient with a mention to the sender' 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)) } @@ -94,7 +97,7 @@ RSpec.describe NotifyService, type: :service do end end - context 'if recipient is NOT supposed to be following sender' do + context 'when recipient is NOT supposed to be following sender' do let(:enabled) { false } it 'does notify' do @@ -124,7 +127,7 @@ RSpec.describe NotifyService, type: :service do end end - context do + context 'with muted and blocked users' do let(:asshole) { Fabricate(:account, username: 'asshole') } let(:reply_to) { Fabricate(:status, account: asshole) } let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, thread: reply_to)) } @@ -141,7 +144,7 @@ RSpec.describe NotifyService, type: :service do end end - context do + context 'with sender as recipient' do let(:sender) { recipient } it 'does not notify when recipient is the sender' do @@ -153,8 +156,8 @@ RSpec.describe NotifyService, type: :service do before do ActionMailer::Base.deliveries.clear - notification_emails = user.settings.notification_emails - user.settings.notification_emails = notification_emails.merge('follow' => enabled) + user.settings.update('notification_emails.follow': enabled) + user.save end context 'when email notification is enabled' do diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index c34f2393a11..1e5c420a63b 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe PostStatusService, type: :service do - subject { PostStatusService.new } + subject { described_class.new } it 'creates a new status' do account = Fabricate(:account) @@ -46,11 +48,11 @@ RSpec.describe PostStatusService, type: :service do expect(status.params['text']).to eq 'Hi future!' expect(status.params['media_ids']).to eq [media.id] expect(media.reload.status).to be_nil - expect(Status.where(text: 'Hi future!').exists?).to be_falsey + expect(Status.where(text: 'Hi future!')).to_not exist 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 @@ -130,7 +132,7 @@ RSpec.describe PostStatusService, type: :service do end it 'processes mentions' do - mention_service = double(:process_mentions_service) + mention_service = instance_double(ProcessMentionsService) allow(mention_service).to receive(:call) allow(ProcessMentionsService).to receive(:new).and_return(mention_service) account = Fabricate(:account) @@ -153,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') @@ -161,7 +163,7 @@ RSpec.describe PostStatusService, type: :service do end it 'processes hashtags' do - hashtags_service = double(:process_hashtags_service) + hashtags_service = instance_double(ProcessHashtagsService) allow(hashtags_service).to receive(:call) allow(ProcessHashtagsService).to receive(:new).and_return(hashtags_service) account = Fabricate(:account) @@ -210,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 86ab59b29eb..663babae8a9 100644 --- a/spec/services/precompute_feed_service_spec.rb +++ b/spec/services/precompute_feed_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe PrecomputeFeedService, type: :service do - subject { PrecomputeFeedService.new } + subject { described_class.new } describe 'call' do let(:account) { Fabricate(:account) } @@ -19,7 +19,7 @@ RSpec.describe PrecomputeFeedService, type: :service do it 'does not raise an error even if it could not find any status' do account = Fabricate(:account) - subject.call(account) + expect { subject.call(account) }.to_not raise_error end it 'filters statuses' do @@ -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/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb index 79ccfa32298..0db73c41fab 100644 --- a/spec/services/process_mentions_service_spec.rb +++ b/spec/services/process_mentions_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ProcessMentionsService, type: :service do - subject { ProcessMentionsService.new } + subject { described_class.new } let(:account) { Fabricate(:account, username: 'alice') } @@ -31,11 +33,11 @@ RSpec.describe ProcessMentionsService, type: :service do end end - context 'resolving a mention to a remote account' do + context 'with resolving a mention to a remote account' do let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}", visibility: :public) } - context 'ActivityPub' do - context do + context 'with ActivityPub' do + context 'with a valid remote user' do let!(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } before do @@ -47,7 +49,7 @@ RSpec.describe ProcessMentionsService, type: :service do end end - context 'mentioning a user several times when not saving records' do + context 'when mentioning a user several times when not saving records' do let!(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct} @#{remote_user.acct} @#{remote_user.acct}", visibility: :public) } @@ -87,7 +89,7 @@ RSpec.describe ProcessMentionsService, type: :service do end end - context 'Temporarily-unreachable ActivityPub user' do + context 'with a Temporarily-unreachable ActivityPub user' do let!(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox', last_webfingered_at: nil) } before do diff --git a/spec/services/purge_domain_service_spec.rb b/spec/services/purge_domain_service_spec.rb index 7d8969ee89e..e96618310ba 100644 --- a/spec/services/purge_domain_service_spec.rb +++ b/spec/services/purge_domain_service_spec.rb @@ -1,12 +1,14 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe PurgeDomainService, type: :service do - subject { PurgeDomainService.new } + subject { described_class.new } let!(:old_account) { Fabricate(:account, domain: 'obsolete.org') } - let!(:old_status1) { Fabricate(:status, account: old_account) } - let!(:old_status2) { Fabricate(:status, account: old_account) } - let!(:old_attachment) { Fabricate(:media_attachment, account: old_account, status: old_status2, file: attachment_fixture('attachment.jpg')) } + 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')) } describe 'for a suspension' do before do @@ -15,8 +17,8 @@ RSpec.describe PurgeDomainService, type: :service do it 'removes the remote accounts\'s statuses and media attachments' do expect { old_account.reload }.to raise_exception ActiveRecord::RecordNotFound - expect { old_status1.reload }.to raise_exception ActiveRecord::RecordNotFound - expect { old_status2.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 diff --git a/spec/services/reblog_service_spec.rb b/spec/services/reblog_service_spec.rb index e2ac0154ce4..7b85e37ed8c 100644 --- a/spec/services/reblog_service_spec.rb +++ b/spec/services/reblog_service_spec.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ReblogService, type: :service do let(:alice) { Fabricate(:account, username: 'alice') } - context 'creates a reblog with appropriate visibility' do - subject { ReblogService.new } + context 'when creates a reblog with appropriate visibility' do + subject { described_class.new } let(:visibility) { :public } let(:reblog_visibility) { :public } @@ -33,10 +35,25 @@ RSpec.describe ReblogService, type: :service do end context 'when the reblogged status is discarded in the meantime' do - let(:status) { Fabricate(:status, account: alice, visibility: :public) } + let(:status) { Fabricate(:status, account: alice, visibility: :public, text: 'discard-status-text') } + # Add a callback to discard the status being reblogged after the + # validations pass but before the database commit is executed. before do - status.discard + Status.class_eval do + before_save :discard_status + def discard_status + Status + .where(id: reblog_of_id) + .where(text: 'discard-status-text') + .update_all(deleted_at: Time.now.utc) # rubocop:disable Rails/SkipsModelValidations + end + end + end + + # Remove race condition simulating `discard_status` callback. + after do + Status._save_callbacks.delete(:discard_status) end it 'raises an exception' do @@ -44,8 +61,8 @@ RSpec.describe ReblogService, type: :service do end end - context 'ActivityPub' do - subject { ReblogService.new } + context 'with ActivityPub' do + subject { described_class.new } let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } let(:status) { Fabricate(:status, account: bob) } diff --git a/spec/services/reject_follow_service_spec.rb b/spec/services/reject_follow_service_spec.rb index 97b7412b92e..d28104b2c78 100644 --- a/spec/services/reject_follow_service_spec.rb +++ b/spec/services/reject_follow_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe RejectFollowService, type: :service do - subject { RejectFollowService.new } + subject { described_class.new } let(:sender) { Fabricate(:account, username: 'alice') } diff --git a/spec/services/remove_from_follwers_service_spec.rb b/spec/services/remove_from_followers_service_spec.rb similarity index 94% rename from spec/services/remove_from_follwers_service_spec.rb rename to spec/services/remove_from_followers_service_spec.rb index 782f859e290..1b29cdcbea3 100644 --- a/spec/services/remove_from_follwers_service_spec.rb +++ b/spec/services/remove_from_followers_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe RemoveFromFollowersService, type: :service do - subject { RemoveFromFollowersService.new } + subject { described_class.new } let(:bob) { Fabricate(:account, username: 'bob') } diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb index e253052f36c..c19b4fac152 100644 --- a/spec/services/remove_status_service_spec.rb +++ b/spec/services/remove_status_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe RemoveStatusService, type: :service do - subject { RemoveStatusService.new } + subject { described_class.new } let!(:alice) { Fabricate(:account) } let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } @@ -26,12 +28,12 @@ RSpec.describe RemoveStatusService, type: :service do it 'removes status from author\'s home feed' do subject.call(@status) - expect(HomeFeed.new(alice).get(10)).to_not include(@status.id) + 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) + expect(HomeFeed.new(jeff).get(10).pluck(:id)).to_not include(@status.id) end it 'sends Delete activity to followers' do diff --git a/spec/services/report_service_spec.rb b/spec/services/report_service_spec.rb index c3a3fddf8a2..d3bcd5d31cb 100644 --- a/spec/services/report_service_spec.rb +++ b/spec/services/report_service_spec.rb @@ -1,30 +1,98 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ReportService, type: :service do subject { described_class.new } let(:source_account) { Fabricate(:account) } + let(:target_account) { Fabricate(:account) } - context 'for a remote account' do + context 'with a local account' do + it 'has a uri' do + report = subject.call(source_account, target_account) + expect(report.uri).to_not be_nil + end + end + + context 'with a remote account' do let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } + let(:forward) { false } before do stub_request(:post, 'http://example.com/inbox').to_return(status: 200) end - it 'sends ActivityPub payload when forward is true' do - subject.call(source_account, remote_account, forward: true) - expect(a_request(:post, 'http://example.com/inbox')).to have_been_made + context 'when forward is true' do + let(:forward) { true } + + it 'sends ActivityPub payload when forward is true' do + subject.call(source_account, remote_account, forward: forward) + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made + end + + it 'has an uri' do + report = subject.call(source_account, remote_account, forward: forward) + expect(report.uri).to_not be_nil + end + + context 'when reporting a reply on a different remote server' do + let(:remote_thread_account) { Fabricate(:account, domain: 'foo.com', protocol: :activitypub, inbox_url: 'http://foo.com/inbox') } + let(:reported_status) { Fabricate(:status, account: remote_account, thread: Fabricate(:status, account: remote_thread_account)) } + + before do + stub_request(:post, 'http://foo.com/inbox').to_return(status: 200) + end + + context 'when forward_to_domains includes both the replied-to domain and the origin domain' do + it 'sends ActivityPub payload to both the author of the replied-to post and the reported user' do + subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward, forward_to_domains: [remote_account.domain, remote_thread_account.domain]) + expect(a_request(:post, 'http://foo.com/inbox')).to have_been_made + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made + end + end + + context 'when forward_to_domains includes only the replied-to domain' do + it 'sends ActivityPub payload only to the author of the replied-to post' do + subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward, forward_to_domains: [remote_thread_account.domain]) + expect(a_request(:post, 'http://foo.com/inbox')).to have_been_made + expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made + end + end + + context 'when forward_to_domains does not include the replied-to domain' do + it 'does not send ActivityPub payload to the author of the replied-to post' do + subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward) + expect(a_request(:post, 'http://foo.com/inbox')).to_not have_been_made + end + end + end + + context 'when reporting a reply on the same remote server as the person being replied-to' do + let(:remote_thread_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } + let(:reported_status) { Fabricate(:status, account: remote_account, thread: Fabricate(:status, account: remote_thread_account)) } + + context 'when forward_to_domains includes both the replied-to domain and the origin domain' do + it 'sends ActivityPub payload only once' do + subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward, forward_to_domains: [remote_account.domain]) + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end + + context 'when forward_to_domains does not include the replied-to domain' do + it 'sends ActivityPub payload only once' do + subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward) + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end + end end - it 'does not send anything when forward is false' do - subject.call(source_account, remote_account, forward: false) - expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made - end - - it 'has an uri' do - report = subject.call(source_account, remote_account, forward: true) - expect(report.uri).to_not be_nil + context 'when forward is false' do + it 'does not send anything' do + subject.call(source_account, remote_account, forward: forward) + expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made + end end end @@ -33,7 +101,6 @@ RSpec.describe ReportService, type: :service do -> { described_class.new.call(source_account, target_account, status_ids: [status.id]) } end - let(:target_account) { Fabricate(:account) } let(:status) { Fabricate(:status, account: target_account, visibility: :direct) } context 'when it is addressed to the reporter' do @@ -89,12 +156,12 @@ RSpec.describe ReportService, type: :service do -> { described_class.new.call(source_account, target_account) } end - let!(:target_account) { Fabricate(:account) } - let!(:other_report) { Fabricate(:report, target_account: target_account) } + let!(:other_report) { Fabricate(:report, target_account: target_account) } before do ActionMailer::Base.deliveries.clear - source_account.user.settings.notification_emails['report'] = true + source_account.user.settings['notification_emails.report'] = true + source_account.user.save end it 'does not send an e-mail' do diff --git a/spec/services/resolve_account_service_spec.rb b/spec/services/resolve_account_service_spec.rb index 02869f8c8c2..f446d0ca6da 100644 --- a/spec/services/resolve_account_service_spec.rb +++ b/spec/services/resolve_account_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe ResolveAccountService, type: :service do @@ -9,11 +11,11 @@ RSpec.describe ResolveAccountService, type: :service do stub_request(:get, 'https://ap.example.com/.well-known/webfinger?resource=acct:foo@ap.example.com').to_return(request_fixture('activitypub-webfinger.txt')) stub_request(:get, 'https://ap.example.com/users/foo').to_return(request_fixture('activitypub-actor.txt')) stub_request(:get, 'https://ap.example.com/users/foo.atom').to_return(request_fixture('activitypub-feed.txt')) - stub_request(:get, %r{https://ap.example.com/users/foo/\w+}).to_return(status: 404) + stub_request(:get, %r{https://ap\.example\.com/users/foo/\w+}).to_return(status: 404) stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:hoge@example.com').to_return(status: 410) end - context 'using skip_webfinger' do + context 'when using skip_webfinger' do context 'when account is known' do let!(:remote_account) { Fabricate(:account, username: 'foo', domain: 'ap.example.com', protocol: 'activitypub') } @@ -76,7 +78,7 @@ RSpec.describe ResolveAccountService, type: :service do end context 'when webfinger returns http gone' do - context 'for a previously known account' do + context 'with a previously known account' do before do Fabricate(:account, username: 'hoge', domain: 'example.com', last_webfingered_at: nil) allow(AccountDeletionWorker).to receive(:perform_async) @@ -92,7 +94,7 @@ RSpec.describe ResolveAccountService, type: :service do end end - context 'for a previously unknown account' do + context 'with a previously unknown account' do it 'returns nil' do expect(subject.call('hoge@example.com')).to be_nil end @@ -207,11 +209,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 3598311ee06..bcfb9dbfb0f 100644 --- a/spec/services/resolve_url_service_spec.rb +++ b/spec/services/resolve_url_service_spec.rb @@ -7,9 +7,9 @@ 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) - service = double + 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 allow(service).to receive(:response_code).and_return(404) @@ -20,8 +20,8 @@ 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) - service = double + known_account = Fabricate(:account, uri: url, domain: 'example.com') + service = instance_double(FetchResourceService) allow(FetchResourceService).to receive(:new).and_return service allow(service).to receive(:response_code).and_return(500) @@ -30,7 +30,7 @@ describe ResolveURLService, type: :service do expect(subject.call(url)).to eq known_account end - context 'searching for a remote private status' do + context 'when searching for a remote private status' do let(:account) { Fabricate(:account) } let(:poster) { Fabricate(:account, domain: 'example.com') } let(:url) { 'https://example.com/@foo/42' } @@ -95,7 +95,7 @@ describe ResolveURLService, type: :service do end end - context 'searching for a local private status' do + context 'when searching for a local private status' do let(:account) { Fabricate(:account) } let(:poster) { Fabricate(:account) } let!(:status) { Fabricate(:status, account: poster, visibility: :private) } @@ -127,7 +127,7 @@ describe ResolveURLService, type: :service do end end - context 'searching for a link that redirects to a local public status' do + context 'when searching for a link that redirects to a local public status' do let(:account) { Fabricate(:account) } let(:poster) { Fabricate(:account) } let!(:status) { Fabricate(:status, account: poster, visibility: :public) } @@ -145,5 +145,35 @@ describe ResolveURLService, type: :service do expect(subject.call(url, on_behalf_of: account)).to eq(status) end end + + context 'when searching for a local link of a remote private status' do + let(:account) { Fabricate(:account) } + let(:poster) { Fabricate(:account, username: 'foo', domain: 'example.com') } + let(:url) { 'https://example.com/@foo/42' } + let(:uri) { 'https://example.com/users/foo/statuses/42' } + let!(:status) { Fabricate(:status, url: url, uri: uri, account: poster, visibility: :private) } + let(:search_url) { "https://#{Rails.configuration.x.local_domain}/@foo@example.com/#{status.id}" } + + before do + stub_request(:get, url).to_return(status: 404) if url.present? + stub_request(:get, uri).to_return(status: 404) + end + + context 'when the account follows the poster' do + before do + account.follow!(poster) + end + + it 'returns the status' do + expect(subject.call(search_url, on_behalf_of: account)).to eq(status) + end + end + + context 'when the account does not follow the poster' do + it 'does not return the status' do + expect(subject.call(search_url, on_behalf_of: account)).to be_nil + end + end + end end end diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index 7ec334a56cc..cb69af5f548 100644 --- a/spec/services/search_service_spec.rb +++ b/spec/services/search_service_spec.rb @@ -23,9 +23,9 @@ describe SearchService, type: :service do @query = 'http://test.host/query' end - context 'that does not find anything' do + context 'when it does not find anything' do it 'returns the empty results' do - service = double(call: nil) + service = instance_double(ResolveURLService, call: nil) allow(ResolveURLService).to receive(:new).and_return(service) results = subject.call(@query, nil, 10, resolve: true) @@ -34,10 +34,10 @@ describe SearchService, type: :service do end end - context 'that finds an account' do + context 'when it finds an account' do it 'includes the account in the results' do account = Account.new - service = double(call: account) + service = instance_double(ResolveURLService, call: account) allow(ResolveURLService).to receive(:new).and_return(service) results = subject.call(@query, nil, 10, resolve: true) @@ -46,10 +46,10 @@ describe SearchService, type: :service do end end - context 'that finds a status' do + context 'when it finds a status' do it 'includes the status in the results' do status = Status.new - service = double(call: status) + service = instance_double(ResolveURLService, call: status) allow(ResolveURLService).to receive(:new).and_return(service) results = subject.call(@query, nil, 10, resolve: true) @@ -60,47 +60,29 @@ describe SearchService, type: :service do end describe 'with a non-url query' do - context 'that matches an account' do + context 'when it matches an account' do it 'includes the account in the results' do query = 'username' account = Account.new - service = double(call: [account]) + service = instance_double(AccountSearchService, call: [account]) 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) + 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 - context 'that matches a tag' do + context 'when it matches a tag' do it 'includes the tag in the results' do query = '#tag' tag = Tag.new - allow(Tag).to receive(:search_for).with('tag', 10, 0, exclude_unreviewed: nil).and_return([tag]) + allow(Tag).to receive(:search_for).with('tag', 10, 0, { exclude_unreviewed: nil }).and_return([tag]) results = subject.call(query, nil, 10) 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 00000000000..c8821348ac2 --- /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 5701090b336..edb70500833 100644 --- a/spec/services/suspend_account_service_spec.rb +++ b/spec/services/suspend_account_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe SuspendAccountService, type: :service do @@ -8,8 +10,7 @@ RSpec.describe SuspendAccountService, type: :service do let!(:list) { Fabricate(:list, account: local_follower) } before do - allow(FeedManager.instance).to receive(:unmerge_from_home).and_return(nil) - allow(FeedManager.instance).to receive(:unmerge_from_list).and_return(nil) + allow(FeedManager.instance).to receive_messages(unmerge_from_home: nil, unmerge_from_list: nil) local_follower.follow!(account) list.accounts << account @@ -24,7 +25,7 @@ RSpec.describe SuspendAccountService, type: :service do end it 'does not change the “suspended” flag' do - expect { subject }.to_not change { account.suspended? } + expect { subject }.to_not change(account, :suspended?) end end @@ -42,8 +43,8 @@ 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!(: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') } let!(:report) { Fabricate(:report, account: remote_reporter, target_account: account) } before do diff --git a/spec/services/translate_status_service_spec.rb b/spec/services/translate_status_service_spec.rb new file mode 100644 index 00000000000..5f6418f5d6f --- /dev/null +++ b/spec/services/translate_status_service_spec.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TranslateStatusService, type: :service do + subject(:service) { described_class.new } + + let(:status) { Fabricate(:status, text: text, spoiler_text: spoiler_text, language: 'en', preloadable_poll: poll, media_attachments: media_attachments) } + let(:text) { 'Hello' } + let(:spoiler_text) { '' } + let(:poll) { nil } + let(:media_attachments) { [] } + + before do + Fabricate(:custom_emoji, shortcode: 'highfive') + end + + describe '#call' do + before do + translation_service = TranslationService.new + allow(translation_service).to receive(:languages).and_return({ 'en' => ['es'] }) + allow(translation_service).to receive(:translate) do |texts| + texts.map do |text| + TranslationService::Translation.new( + text: text.gsub('Hello', 'Hola').gsub('higfive', 'cincoaltos'), + detected_source_language: 'en', + provider: 'Dummy' + ) + end + end + + allow(TranslationService).to receive_messages(configured?: true, configured: translation_service) + end + + it 'returns translated status content' do + expect(service.call(status, 'es').content).to eq '

Hola

' + end + + it 'returns source language' do + expect(service.call(status, 'es').detected_source_language).to eq 'en' + end + + it 'returns translation provider' do + expect(service.call(status, 'es').provider).to eq 'Dummy' + end + + it 'returns original status' do + expect(service.call(status, 'es').status).to eq status + end + + describe 'status has content with custom emoji' do + let(:text) { 'Hello & :highfive:' } + + it 'does not translate shortcode' do + expect(service.call(status, 'es').content).to eq '

Hola & :highfive:

' + end + end + + describe 'status has no spoiler_text' do + it 'returns an empty string' do + expect(service.call(status, 'es').spoiler_text).to eq '' + end + end + + describe 'status has spoiler_text' do + let(:spoiler_text) { 'Hello & Hello!' } + + it 'translates the spoiler text' do + expect(service.call(status, 'es').spoiler_text).to eq 'Hola & Hola!' + end + end + + describe 'status has spoiler_text with custom emoji' do + let(:spoiler_text) { 'Hello :highfive:' } + + it 'does not translate shortcode' do + expect(service.call(status, 'es').spoiler_text).to eq 'Hola :highfive:' + end + end + + describe 'status has spoiler_text with unmatched custom emoji' do + let(:spoiler_text) { 'Hello :Hello:' } + + it 'translates the invalid shortcode' do + expect(service.call(status, 'es').spoiler_text).to eq 'Hola :Hola:' + end + end + + describe 'status has poll' do + let(:poll) { Fabricate(:poll, options: ['Hello 1', 'Hello 2']) } + + it 'translates the poll option title' do + status_translation = service.call(status, 'es') + expect(status_translation.poll_options.size).to eq 2 + expect(status_translation.poll_options.first.title).to eq 'Hola 1' + end + end + + describe 'status has media attachment' do + let(:media_attachments) { [Fabricate(:media_attachment, description: 'Hello & :highfive:')] } + + it 'translates the media attachment description' do + status_translation = service.call(status, 'es') + + media_attachment = status_translation.media_attachments.first + expect(media_attachment.id).to eq media_attachments.first.id + expect(media_attachment.description).to eq 'Hola & :highfive:' + end + end + end + + describe '#source_texts' do + before do + service.instance_variable_set(:@status, status) + end + + describe 'status only has content' do + it 'returns formatted content' do + expect(service.send(:source_texts)).to eq({ content: '

Hello

' }) + end + end + + describe 'status content contains custom emoji' do + let(:status) { Fabricate(:status, text: 'Hello :highfive:') } + + it 'returns formatted content' do + source_texts = service.send(:source_texts) + expect(source_texts[:content]).to eq '

Hello :highfive:

' + end + end + + describe 'status content contains tags' do + let(:status) { Fabricate(:status, text: 'Hello #hola') } + + it 'returns formatted content' do + source_texts = service.send(:source_texts) + expect(source_texts[:content]).to include '

Hello :highfive:' + end + end + + describe 'status has poll' do + let(:poll) { Fabricate(:poll, options: %w(Blue Green)) } + + context 'with source texts from the service' do + let!(:source_texts) { service.send(:source_texts) } + + it 'returns formatted poll options' do + expect(source_texts.size).to eq 3 + expect(source_texts.values).to eq %w(

Hello

Blue Green) + end + + it 'has a first key with content' do + expect(source_texts.keys.first).to eq :content + end + + it 'has the first option in the second key with correct options' do + option1 = source_texts.keys.second + expect(option1).to be_a Poll::Option + expect(option1.id).to eq '0' + expect(option1.title).to eq 'Blue' + end + + it 'has the second option in the third key with correct options' do + option2 = source_texts.keys.third + expect(option2).to be_a Poll::Option + expect(option2.id).to eq '1' + expect(option2.title).to eq 'Green' + end + end + end + + describe 'status has poll with custom emoji' do + let(:poll) { Fabricate(:poll, options: ['Blue', 'Green :highfive:']) } + + it 'returns formatted poll options' do + html = service.send(:source_texts).values.last + expect(html).to eq 'Green :highfive:' + end + end + + describe 'status has media attachments' do + let(:text) { '' } + let(:media_attachments) { [Fabricate(:media_attachment, description: 'Hello :highfive:')] } + + it 'returns media attachments without custom emoji rendering' do + source_texts = service.send(:source_texts) + expect(source_texts.size).to eq 1 + + key, text = source_texts.first + expect(key).to eq media_attachments.first + expect(text).to eq 'Hello :highfive:' + end + end + end + + describe '#wrap_emoji_shortcodes' do + before do + service.instance_variable_set(:@status, status) + end + + describe 'string contains custom emoji' do + let(:text) { ':highfive:' } + + it 'renders the emoji' do + html = service.send(:wrap_emoji_shortcodes, 'Hello :highfive:'.html_safe) + expect(html).to eq 'Hello :highfive:' + end + end + end + + describe '#unwrap_emoji_shortcodes' do + describe 'string contains custom emoji' do + it 'inserts the shortcode' do + fragment = service.send(:unwrap_emoji_shortcodes, '

Hello :highfive:!

') + expect(fragment.to_html).to eq '

Hello :highfive:!

' + end + + it 'preserves other attributes than translate=no' do + fragment = service.send(:unwrap_emoji_shortcodes, '

Hello :highfive:!

') + expect(fragment.to_html).to eq '

Hello :highfive:!

' + end + end + end +end diff --git a/spec/services/unallow_domain_service_spec.rb b/spec/services/unallow_domain_service_spec.rb index ae7d00c7d38..19d40e7e869 100644 --- a/spec/services/unallow_domain_service_spec.rb +++ b/spec/services/unallow_domain_service_spec.rb @@ -1,18 +1,20 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe UnallowDomainService, type: :service do - subject { UnallowDomainService.new } + subject { described_class.new } let!(:bad_account) { Fabricate(:account, username: 'badguy666', domain: 'evil.org') } - let!(:bad_status1) { Fabricate(:status, account: bad_account, text: 'You suck') } - let!(:bad_status2) { Fabricate(:status, account: bad_account, text: 'Hahaha') } - let!(:bad_attachment) { Fabricate(:media_attachment, account: bad_account, status: bad_status2, file: attachment_fixture('attachment.jpg')) } + 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') } - context 'in limited federation mode' do + context 'with limited federation mode' do before do - allow(subject).to receive(:whitelist_mode?).and_return(true) + allow(Rails.configuration.x).to receive(:limited_federation_mode).and_return(true) end describe '#call' do @@ -29,8 +31,8 @@ RSpec.describe UnallowDomainService, type: :service do end it 'removes the remote accounts\'s statuses and media attachments' do - expect { bad_status1.reload }.to raise_exception ActiveRecord::RecordNotFound - expect { bad_status2.reload }.to raise_exception ActiveRecord::RecordNotFound + 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 end end @@ -38,7 +40,7 @@ RSpec.describe UnallowDomainService, type: :service do context 'without limited federation mode' do before do - allow(subject).to receive(:whitelist_mode?).and_return(false) + allow(Rails.configuration.x).to receive(:limited_federation_mode).and_return(false) end describe '#call' do @@ -55,8 +57,8 @@ RSpec.describe UnallowDomainService, type: :service do end it 'removes the remote accounts\'s statuses and media attachments' do - expect { bad_status1.reload }.to_not raise_error - expect { bad_status2.reload }.to_not raise_error + 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 diff --git a/spec/services/unblock_service_spec.rb b/spec/services/unblock_service_spec.rb index bd24005f667..86632c39388 100644 --- a/spec/services/unblock_service_spec.rb +++ b/spec/services/unblock_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe UnblockService, type: :service do - subject { UnblockService.new } + subject { described_class.new } let(:sender) { Fabricate(:account, username: 'alice') } diff --git a/spec/services/unfollow_service_spec.rb b/spec/services/unfollow_service_spec.rb index 55969bef937..3e65e610ba5 100644 --- a/spec/services/unfollow_service_spec.rb +++ b/spec/services/unfollow_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe UnfollowService, type: :service do - subject { UnfollowService.new } + subject { described_class.new } let(:sender) { Fabricate(:account, username: 'alice') } diff --git a/spec/services/unmute_service_spec.rb b/spec/services/unmute_service_spec.rb deleted file mode 100644 index 8463eb283f3..00000000000 --- a/spec/services/unmute_service_spec.rb +++ /dev/null @@ -1,5 +0,0 @@ -require 'rails_helper' - -RSpec.describe UnmuteService, type: :service do - subject { UnmuteService.new } -end diff --git a/spec/services/unsuspend_account_service_spec.rb b/spec/services/unsuspend_account_service_spec.rb index 6675074690d..c555b661ecb 100644 --- a/spec/services/unsuspend_account_service_spec.rb +++ b/spec/services/unsuspend_account_service_spec.rb @@ -1,15 +1,16 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe UnsuspendAccountService, type: :service do - shared_examples 'common behavior' do + shared_context 'with common context' do subject { described_class.new.call(account) } let!(:local_follower) { Fabricate(:user, current_sign_in_at: 1.hour.ago).account } 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 @@ -31,13 +32,13 @@ RSpec.describe UnsuspendAccountService, type: :service do end it 'does not change the “suspended” flag' do - expect { subject }.to_not change { account.suspended? } + expect { subject }.to_not change(account, :suspended?) end - include_examples 'common behavior' 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!(: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') } let!(:report) { Fabricate(:report, account: remote_reporter, target_account: account) } before do @@ -59,9 +60,9 @@ RSpec.describe UnsuspendAccountService, type: :service do end describe 'unsuspending a remote account' do - include_examples 'common behavior' do + include_examples 'with common context' do let!(:account) { Fabricate(:account, domain: 'bob.com', uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } - let!(:resolve_account_service) { double } + let!(:resolve_account_service) { instance_double(ResolveAccountService) } before do allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service) @@ -84,7 +85,7 @@ RSpec.describe UnsuspendAccountService, type: :service do end it 'does not change the “suspended” flag' do - expect { subject }.to_not change { account.suspended? } + expect { subject }.to_not change(account, :suspended?) end end @@ -108,7 +109,7 @@ RSpec.describe UnsuspendAccountService, type: :service do end it 'marks account as suspended' do - expect { subject }.to change { account.suspended? }.from(false).to(true) + expect { subject }.to change(account, :suspended?).from(false).to(true) end end diff --git a/spec/services/update_account_service_spec.rb b/spec/services/update_account_service_spec.rb index c2dc791e4dc..6318cc95fbb 100644 --- a/spec/services/update_account_service_spec.rb +++ b/spec/services/update_account_service_spec.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe UpdateAccountService, type: :service do - subject { UpdateAccountService.new } + subject { described_class.new } describe 'switching form locked to unlocked accounts' do let(:account) { Fabricate(:account, locked: true) } diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb index a7364ca8b09..9c53ebb2fd8 100644 --- a/spec/services/update_status_service_spec.rb +++ b/spec/services/update_status_service_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe UpdateStatusService, type: :service do @@ -118,7 +120,9 @@ RSpec.describe UpdateStatusService, type: :service do before do status.update(poll: poll) VoteService.new.call(voter, poll, [0]) - subject.call(status, status.account_id, text: 'Foo', poll: { options: %w(Bar Baz Foo), expires_in: 5.days.to_i }) + 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 end it 'updates poll' do @@ -136,6 +140,11 @@ RSpec.describe UpdateStatusService, type: :service do it 'saves edit history' do expect(status.edits.pluck(:poll_options)).to eq [%w(Foo Bar), %w(Bar Baz Foo)] end + + it 'requeues expiration notification' do + poll = status.poll.reload + expect(PollExpirationNotifyWorker).to have_enqueued_sidekiq_job(poll.id).at(poll.expires_at + 5.minutes) + end end context 'when mentions in text change' do @@ -153,7 +162,7 @@ RSpec.describe UpdateStatusService, type: :service do end it 'keeps old mentions as silent mentions' do - expect(status.mentions.pluck(:account_id)).to match_array([alice.id, bob.id]) + expect(status.mentions.pluck(:account_id)).to contain_exactly(alice.id, bob.id) end end diff --git a/spec/services/verify_link_service_spec.rb b/spec/services/verify_link_service_spec.rb index 8f65f3a8462..415788cb585 100644 --- a/spec/services/verify_link_service_spec.rb +++ b/spec/services/verify_link_service_spec.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + require 'rails_helper' RSpec.describe VerifyLinkService, type: :service do subject { described_class.new } - context 'given a local account' do + context 'when given a local account' do let(:account) { Fabricate(:account, username: 'alice') } let(:field) { Account::Field.new(account, 'name' => 'Website', 'value' => 'http://example.com') } @@ -127,7 +129,7 @@ RSpec.describe VerifyLinkService, type: :service do end end - context 'given a remote account' do + context 'when given a remote account' do let(:account) { Fabricate(:account, username: 'alice', domain: 'example.com', url: 'https://profile.example.com/alice') } let(:field) { Account::Field.new(account, 'name' => 'Website', 'value' => 'example.com') } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 97b8d83c578..6ff0a8f8420 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,16 +1,17 @@ -GC.disable +# frozen_string_literal: true if ENV['DISABLE_SIMPLECOV'] != 'true' require 'simplecov' SimpleCov.start 'rails' do - add_group 'Services', 'app/services' + add_filter 'lib/linter' + 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 end -gc_counter = -1 - RSpec.configure do |config| config.example_status_persistence_file_path = 'tmp/rspec/examples.txt' config.expect_with :rspec do |expectations| @@ -33,20 +34,13 @@ RSpec.configure do |config| end config.after :suite do - gc_counter = 0 - FileUtils.rm_rf(Dir["#{Rails.root}/spec/test_files/"]) + FileUtils.rm_rf(Dir[Rails.root.join('spec', 'test_files')]) end - config.after :each do - gc_counter += 1 - - if gc_counter > 19 - GC.enable - GC.start - GC.disable - - gc_counter = 0 - 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 @@ -64,3 +58,122 @@ def expect_push_bulk_to_match(klass, matcher) 'args' => matcher, })) end + +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 + +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 diff --git a/spec/support/examples/api.rb b/spec/support/examples/api.rb new file mode 100644 index 00000000000..d531860abfd --- /dev/null +++ b/spec/support/examples/api.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +shared_examples 'forbidden for wrong scope' do |wrong_scope| + let(:scopes) { wrong_scope } + + it 'returns http forbidden' do + # Some examples have a subject which needs to be called to make a request + subject if request.nil? + + expect(response).to have_http_status(403) + end +end + +shared_examples 'forbidden for wrong role' do |wrong_role| + let(:role) { UserRole.find_by(name: wrong_role) } + + it 'returns http forbidden' do + # Some examples have a subject which needs to be called to make a request + subject if request.nil? + + expect(response).to have_http_status(403) + end +end diff --git a/spec/support/examples/cache.rb b/spec/support/examples/cache.rb new file mode 100644 index 00000000000..43cfbade824 --- /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/lib/admin/checks.rb b/spec/support/examples/lib/admin/checks.rb new file mode 100644 index 00000000000..b50faa77ba0 --- /dev/null +++ b/spec/support/examples/lib/admin/checks.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +shared_examples 'a check available to devops users' do + describe 'skip?' do + 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 + end + + 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 + end +end diff --git a/spec/support/examples/lib/settings/scoped_settings.rb b/spec/support/examples/lib/settings/scoped_settings.rb deleted file mode 100644 index 106adb4fac2..00000000000 --- a/spec/support/examples/lib/settings/scoped_settings.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -shared_examples 'ScopedSettings' do - describe '[]' do - it 'inherits default settings' do - expect(Setting.boost_modal).to be false - expect(Setting.interactions['must_be_follower']).to be false - - settings = create! - - expect(settings['boost_modal']).to be false - expect(settings['interactions']['must_be_follower']).to be false - end - end - - describe 'all_as_records' do - # expecting [] and []= works - - it 'returns records merged with default values except hashes' do - expect(Setting.boost_modal).to be false - expect(Setting.delete_modal).to be true - - settings = create! - settings['boost_modal'] = true - - records = settings.all_as_records - - expect(records['boost_modal'].value).to be true - expect(records['delete_modal'].value).to be true - end - end - - describe 'missing methods' do - # expecting [] and []= works. - - it 'reads settings' do - expect(Setting.boost_modal).to be false - settings = create! - expect(settings.boost_modal).to be false - end - - it 'updates settings' do - settings = fabricate - settings.boost_modal = true - expect(settings['boost_modal']).to be true - end - end - - it 'can update settings with [] and can read with []=' do - settings = fabricate - - settings['boost_modal'] = true - settings['interactions'] = settings['interactions'].merge('must_be_follower' => true) - - Setting.save! - - expect(settings['boost_modal']).to be true - expect(settings['interactions']['must_be_follower']).to be true - - Rails.cache.clear - - expect(settings['boost_modal']).to be true - expect(settings['interactions']['must_be_follower']).to be true - end - - xit 'does not mutate defaults via the cache' do - fabricate['interactions']['must_be_follower'] = true - # TODO - # This mutates the global settings default such that future - # instances will inherit the incorrect starting values - - expect(fabricate.settings['interactions']['must_be_follower']).to be false - end -end diff --git a/spec/support/examples/lib/settings/settings_extended.rb b/spec/support/examples/lib/settings/settings_extended.rb deleted file mode 100644 index 5a9d34bb041..00000000000 --- a/spec/support/examples/lib/settings/settings_extended.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -shared_examples 'Settings-extended' do - describe 'settings' do - def fabricate - super.settings - end - - def create! - super.settings - end - - it_behaves_like 'ScopedSettings' - end -end diff --git a/spec/support/examples/mailers.rb b/spec/support/examples/mailers.rb new file mode 100644 index 00000000000..213e873b4e1 --- /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 2180f52739f..16ebda56415 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' do + describe 'static avatars', paperclip_processing: true 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' do + describe 'base64-encoded files', paperclip_processing: true 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 77ee0e62901..d65f54f00f7 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' do + describe 'base64-encoded files', paperclip_processing: true 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/matchers/json/match_json_schema.rb b/spec/support/matchers/json/match_json_schema.rb index 5d9c9a618ee..3a275199efd 100644 --- a/spec/support/matchers/json/match_json_schema.rb +++ b/spec/support/matchers/json/match_json_schema.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + RSpec::Matchers.define :match_json_schema do |schema| match do |input_json| schema_path = Rails.root.join('spec', 'support', 'schema', "#{schema}.json").to_s diff --git a/spec/support/matchers/model/model_have_error_on_field.rb b/spec/support/matchers/model/model_have_error_on_field.rb index d85db2fcadd..0f9c81a475a 100644 --- a/spec/support/matchers/model/model_have_error_on_field.rb +++ b/spec/support/matchers/model/model_have_error_on_field.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + RSpec::Matchers.define :model_have_error_on_field do |expected| match do |record| record.valid? if record.errors.empty? - record.errors.has_key?(expected) + record.errors.key?(expected) end failure_message do |record| diff --git a/spec/support/omniauth_mocks.rb b/spec/support/omniauth_mocks.rb new file mode 100644 index 00000000000..9883adec7a6 --- /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/signed_request_helpers.rb b/spec/support/signed_request_helpers.rb new file mode 100644 index 00000000000..33d7dba6b87 --- /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 de7ae17e633..82667ca080c 100644 --- a/spec/support/stories/profile_stories.rb +++ b/spec/support/stories/profile_stories.rb @@ -9,6 +9,8 @@ module ProfileStories email: email, password: password, confirmed_at: confirmed_at, account: Fabricate(:account, username: 'bob') ) + + Web::Setting.where(user: bob).first_or_initialize(user: bob).update!(data: { introductionVersion: 201812160442020 }) if finished_onboarding # rubocop:disable Style/NumericLiterals end def as_a_logged_in_user @@ -16,7 +18,7 @@ module ProfileStories visit new_user_session_path fill_in 'user_email', with: email fill_in 'user_password', with: password - click_on I18n.t('auth.login') + click_button I18n.t('auth.login') end def with_alice_as_local_user @@ -42,4 +44,8 @@ module ProfileStories def password @password ||= 'password' end + + def finished_onboarding + @finished_onboarding || false + end end diff --git a/spec/system/new_statuses_spec.rb b/spec/system/new_statuses_spec.rb new file mode 100644 index 00000000000..244101f4d4f --- /dev/null +++ b/spec/system/new_statuses_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'NewStatuses' do + include ProfileStories + + subject { page } + + let(:email) { 'test@example.com' } + let(:password) { 'password' } + let(:confirmed_at) { Time.zone.now } + let(:finished_onboarding) { true } + + before do + as_a_logged_in_user + visit root_path + end + + it 'can be posted' do + expect(subject).to have_css('div.app-holder') + + status_text = 'This is a new status!' + + within('.compose-form') do + fill_in "What's on your mind?", with: status_text + click_button 'Publish!' + end + + expect(subject).to have_css('.status__content__text', text: status_text) + end + + it 'can be posted again' do + expect(subject).to have_css('div.app-holder') + + status_text = 'This is a second status!' + + within('.compose-form') do + fill_in "What's on your mind?", with: status_text + click_button 'Publish!' + end + + expect(subject).to have_css('.status__content__text', text: status_text) + end +end diff --git a/spec/validators/blacklisted_email_validator_spec.rb b/spec/validators/blacklisted_email_validator_spec.rb index a642405ae61..bfe2a11a999 100644 --- a/spec/validators/blacklisted_email_validator_spec.rb +++ b/spec/validators/blacklisted_email_validator_spec.rb @@ -6,19 +6,20 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do describe '#validate' do subject { described_class.new.validate(user); errors } - let(:user) { double(email: 'info@mail.com', sign_up_ip: '1.2.3.4', errors: errors) } - let(:errors) { double(add: nil) } + let(:user) { instance_double(User, email: 'info@mail.com', sign_up_ip: '1.2.3.4', errors: errors) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } 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 2c4ebc4f254..7144d289188 100644 --- a/spec/validators/disallowed_hashtags_validator_spec.rb +++ b/spec/validators/disallowed_hashtags_validator_spec.rb @@ -11,10 +11,10 @@ RSpec.describe DisallowedHashtagsValidator, type: :validator do described_class.new.validate(status) end - let(:status) { double(errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| '#' + x }.join(' ')) } - let(:errors) { double(add: nil) } + let(:status) { instance_double(Status, errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| "##{x}" }.join(' ')) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } - context 'for a remote reblog' do + context 'with a remote reblog' do let(:local) { false } let(:reblog) { true } @@ -23,7 +23,7 @@ RSpec.describe DisallowedHashtagsValidator, type: :validator do end end - context 'for a local original status' do + context 'with a local original status' do let(:local) { true } let(:reblog) { false } diff --git a/spec/validators/email_mx_validator_spec.rb b/spec/validators/email_mx_validator_spec.rb index ffb6851d09b..876d73c1842 100644 --- a/spec/validators/email_mx_validator_spec.rb +++ b/spec/validators/email_mx_validator_spec.rb @@ -4,9 +4,9 @@ require 'rails_helper' describe EmailMxValidator do describe '#validate' do - let(:user) { double(email: 'foo@example.com', sign_up_ip: '1.2.3.4', errors: double(add: nil)) } + let(:user) { instance_double(User, email: 'foo@example.com', sign_up_ip: '1.2.3.4', errors: instance_double(ActiveModel::Errors, add: nil)) } - context 'for an e-mail domain that is explicitly allowed' do + context 'with an e-mail domain that is explicitly allowed' do around do |block| tmp = Rails.configuration.x.email_domains_whitelist Rails.configuration.x.email_domains_whitelist = 'example.com' @@ -15,7 +15,7 @@ describe EmailMxValidator do end it 'does not add errors if there are no DNS records' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) @@ -29,7 +29,7 @@ describe EmailMxValidator do end it 'adds no error if there are DNS records for the e-mail domain' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([Resolv::DNS::Resource::IN::A.new('192.0.2.42')]) @@ -41,8 +41,24 @@ describe EmailMxValidator do expect(user.errors).to_not have_received(:add) end + it 'adds an error if the TagManager fails to normalize domain' do + double = instance_double(TagManager) + allow(TagManager).to receive(:instance).and_return(double) + allow(double).to receive(:normalize_domain).with('example.com').and_raise(Addressable::URI::InvalidURIError) + + user = instance_double(User, email: 'foo@example.com', errors: instance_double(ActiveModel::Errors, add: nil)) + subject.validate(user) + expect(user.errors).to have_received(:add) + end + + it 'adds an error if the domain email portion is blank' do + user = instance_double(User, email: 'foo@', errors: instance_double(ActiveModel::Errors, add: nil)) + subject.validate(user) + expect(user.errors).to have_received(:add) + end + it 'adds an error if the email domain name contains empty labels' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getresources).with('example..com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example..com', Resolv::DNS::Resource::IN::A).and_return([Resolv::DNS::Resource::IN::A.new('192.0.2.42')]) @@ -50,13 +66,13 @@ describe EmailMxValidator do allow(resolver).to receive(:timeouts=).and_return(nil) allow(Resolv::DNS).to receive(:open).and_yield(resolver) - user = double(email: 'foo@example..com', sign_up_ip: '1.2.3.4', errors: double(add: nil)) + user = instance_double(User, email: 'foo@example..com', sign_up_ip: '1.2.3.4', errors: instance_double(ActiveModel::Errors, add: nil)) subject.validate(user) expect(user.errors).to have_received(:add) end it 'adds an error if there are no DNS records for the e-mail domain' do - resolver = double + resolver = instance_double(Resolv::DNS) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) @@ -69,9 +85,11 @@ describe EmailMxValidator do end it 'adds an error if a MX record does not lead to an IP' do - resolver = double + resolver = instance_double(Resolv::DNS) - allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) + allow(resolver).to receive(:getresources) + .with('example.com', Resolv::DNS::Resource::IN::MX) + .and_return([instance_double(Resolv::DNS::Resource::MX, exchange: 'mail.example.com')]) 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([]) @@ -85,13 +103,15 @@ describe EmailMxValidator do it 'adds an error if the MX record is blacklisted' do EmailDomainBlock.create!(domain: 'mail.example.com') - resolver = double + resolver = instance_double(Resolv::DNS) - allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) + allow(resolver).to receive(:getresources) + .with('example.com', Resolv::DNS::Resource::IN::MX) + .and_return([instance_double(Resolv::DNS::Resource::MX, exchange: 'mail.example.com')]) 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([double(address: '2.3.4.5')]) - allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([double(address: 'fd00::2')]) + 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(: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 00000000000..4f1dd55a17b --- /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 94ba0c47f84..86b6511d655 100644 --- a/spec/validators/follow_limit_validator_spec.rb +++ b/spec/validators/follow_limit_validator_spec.rb @@ -12,13 +12,13 @@ RSpec.describe FollowLimitValidator, type: :validator do described_class.new.validate(follow) end - let(:follow) { double(account: account, errors: errors) } - let(:errors) { double(add: nil) } - let(:account) { double(nil?: _nil, local?: local, following_count: 0, followers_count: 0) } + 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 'follow.account.nil? || !follow.account.local?' do + context 'with follow.account.nil? || !follow.account.local?' do let(:_nil) { true } it 'not calls errors.add' do @@ -26,11 +26,11 @@ RSpec.describe FollowLimitValidator, type: :validator do end end - context '!(follow.account.nil? || !follow.account.local?)' do + context 'with !(follow.account.nil? || !follow.account.local?)' do let(:_nil) { false } let(:local) { true } - context 'limit_reached?' do + context 'when limit_reached?' do let(:limit_reached) { true } it 'calls errors.add' do @@ -39,7 +39,7 @@ RSpec.describe FollowLimitValidator, type: :validator do end end - context '!limit_reached?' do + context 'with !limit_reached?' do let(:limit_reached) { false } it 'not calls errors.add' do diff --git a/spec/validators/language_validator_spec.rb b/spec/validators/language_validator_spec.rb new file mode 100644 index 00000000000..cb693dcd81f --- /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/note_length_validator_spec.rb b/spec/validators/note_length_validator_spec.rb index 390ac8d904d..66fccad3ece 100644 --- a/spec/validators/note_length_validator_spec.rb +++ b/spec/validators/note_length_validator_spec.rb @@ -3,12 +3,12 @@ require 'rails_helper' describe NoteLengthValidator do - subject { NoteLengthValidator.new(attributes: { note: true }, maximum: 500) } + subject { described_class.new(attributes: { note: true }, maximum: 500) } describe '#validate' do it 'adds an error when text is over 500 characters' do text = 'a' * 520 - account = double(note: text, errors: double(add: nil)) + account = instance_double(Account, note: text, errors: activemodel_errors) subject.validate_each(account, 'note', text) expect(account.errors).to have_received(:add) @@ -16,7 +16,7 @@ describe NoteLengthValidator do it 'counts URLs as 23 characters flat' do text = ('a' * 476) + " http://#{'b' * 30}.com/example" - account = double(note: text, errors: double(add: nil)) + account = instance_double(Account, note: text, errors: activemodel_errors) subject.validate_each(account, 'note', text) expect(account.errors).to_not have_received(:add) @@ -24,10 +24,16 @@ describe NoteLengthValidator do it 'does not count non-autolinkable URLs as 23 characters flat' do text = ('a' * 476) + "http://#{'b' * 30}.com/example" - account = double(note: text, errors: double(add: nil)) + account = instance_double(Account, note: text, errors: activemodel_errors) subject.validate_each(account, 'note', text) expect(account.errors).to have_received(:add) end + + private + + def activemodel_errors + instance_double(ActiveModel::Errors, add: nil) + end end end diff --git a/spec/validators/poll_validator_spec.rb b/spec/validators/poll_validator_spec.rb index f3f4b128819..95feb043dbb 100644 --- a/spec/validators/poll_validator_spec.rb +++ b/spec/validators/poll_validator_spec.rb @@ -9,8 +9,8 @@ RSpec.describe PollValidator, type: :validator do end let(:validator) { described_class.new } - let(:poll) { double(options: options, expires_at: expires_at, errors: errors) } - let(:errors) { double(add: nil) } + let(:poll) { instance_double(Poll, options: options, expires_at: expires_at, errors: errors) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } let(:options) { %w(foo bar) } let(:expires_at) { 1.day.from_now } @@ -18,7 +18,7 @@ RSpec.describe PollValidator, type: :validator do expect(errors).to_not have_received(:add) end - context 'expires just 5 min ago' do + context 'when expires is just 5 min ago' do let(:expires_at) { 5.minutes.from_now } it 'not calls errors add' do diff --git a/spec/validators/status_length_validator_spec.rb b/spec/validators/status_length_validator_spec.rb index e132b5618a5..98ea15e03bb 100644 --- a/spec/validators/status_length_validator_spec.rb +++ b/spec/validators/status_length_validator_spec.rb @@ -5,38 +5,38 @@ require 'rails_helper' describe StatusLengthValidator do describe '#validate' do it 'does not add errors onto remote statuses' do - status = double(local?: false) + status = instance_double(Status, local?: false) subject.validate(status) expect(status).to_not receive(:errors) end it 'does not add errors onto local reblogs' do - status = double(local?: false, reblog?: true) + status = instance_double(Status, local?: false, reblog?: true) subject.validate(status) expect(status).to_not receive(:errors) end it 'adds an error when content warning is over 500 characters' do - status = double(spoiler_text: 'a' * 520, text: '', errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: 'a' * 520, text: '', errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end it 'adds an error when text is over 500 characters' do - status = double(spoiler_text: '', text: 'a' * 520, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: 'a' * 520, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end it 'adds an error when text and content warning are over 500 characters total' do - status = double(spoiler_text: 'a' * 250, text: 'b' * 251, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: 'a' * 250, text: 'b' * 251, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end it 'counts URLs as 23 characters flat' do text = ('a' * 476) + " http://#{'b' * 30}.com/example" - status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to_not have_received(:add) @@ -44,7 +44,7 @@ describe StatusLengthValidator do it 'does not count non-autolinkable URLs as 23 characters flat' do text = ('a' * 476) + "http://#{'b' * 30}.com/example" - status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) @@ -52,14 +52,14 @@ describe StatusLengthValidator do it 'does not count overly long URLs as 23 characters flat' do text = "http://example.com/valid?#{'#foo?' * 1000}" - status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end it 'counts only the front part of remote usernames' do text = ('a' * 475) + " @alice@#{'b' * 30}.com" - status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to_not have_received(:add) @@ -67,10 +67,16 @@ describe StatusLengthValidator do it 'does count both parts of remote usernames for overly long domains' do text = "@alice@#{'b' * 500}.com" - status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false) + status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false) subject.validate(status) expect(status.errors).to have_received(:add) end end + + private + + def activemodel_errors + instance_double(ActiveModel::Errors, add: nil) + end end diff --git a/spec/validators/status_pin_validator_spec.rb b/spec/validators/status_pin_validator_spec.rb index d5bd0d1b837..e8f8a454348 100644 --- a/spec/validators/status_pin_validator_spec.rb +++ b/spec/validators/status_pin_validator_spec.rb @@ -8,11 +8,11 @@ RSpec.describe StatusPinValidator, type: :validator do subject.validate(pin) end - let(:pin) { double(account: account, errors: errors, status: status, account_id: pin_account_id) } - let(:status) { double(reblog?: reblog, account_id: status_account_id, visibility: visibility, direct_visibility?: visibility == 'direct') } - let(:account) { double(status_pins: status_pins, local?: local) } - let(:status_pins) { double(count: count) } - let(:errors) { double(add: nil) } + let(:pin) { instance_double(StatusPin, account: account, errors: errors, status: status, account_id: pin_account_id) } + let(:status) { instance_double(Status, reblog?: reblog, account_id: status_account_id, visibility: visibility, direct_visibility?: visibility == 'direct') } + let(:account) { instance_double(Account, status_pins: status_pins, local?: local) } + let(:status_pins) { instance_double(Array, count: count) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } let(:pin_account_id) { 1 } let(:status_account_id) { 1 } let(:visibility) { 'public' } @@ -20,7 +20,7 @@ RSpec.describe StatusPinValidator, type: :validator do let(:reblog) { false } let(:count) { 0 } - context 'pin.status.reblog?' do + context 'when pin.status.reblog?' do let(:reblog) { true } it 'calls errors.add' do @@ -28,7 +28,7 @@ RSpec.describe StatusPinValidator, type: :validator do end end - context 'pin.account_id != pin.status.account_id' do + context 'when pin.account_id != pin.status.account_id' do let(:pin_account_id) { 1 } let(:status_account_id) { 2 } @@ -37,7 +37,7 @@ RSpec.describe StatusPinValidator, type: :validator do end end - context 'if pin.status.direct_visibility?' do + context 'when pin.status.direct_visibility?' do let(:visibility) { 'direct' } it 'calls errors.add' do @@ -45,7 +45,7 @@ RSpec.describe StatusPinValidator, type: :validator do end end - context 'pin.account.status_pins.count > 4 && pin.account.local?' do + context 'when pin.account.status_pins.count > 4 && pin.account.local?' do let(:count) { 5 } let(:local) { true } diff --git a/spec/validators/unique_username_validator_spec.rb b/spec/validators/unique_username_validator_spec.rb index 6867cbc6ce2..0d172c84089 100644 --- a/spec/validators/unique_username_validator_spec.rb +++ b/spec/validators/unique_username_validator_spec.rb @@ -6,7 +6,7 @@ describe UniqueUsernameValidator do describe '#validate' do context 'when local account' do it 'does not add errors if username is nil' do - account = double(username: nil, domain: nil, persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: nil, domain: nil, persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to_not have_received(:add) end @@ -18,14 +18,14 @@ describe UniqueUsernameValidator do it 'adds an error when the username is already used with ignoring cases' do Fabricate(:account, username: 'ABCdef') - account = double(username: 'abcDEF', domain: nil, persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: 'abcDEF', domain: nil, persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to have_received(:add) end it 'does not add errors when same username remote account exists' do Fabricate(:account, username: 'abcdef', domain: 'example.com') - account = double(username: 'abcdef', domain: nil, persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: 'abcdef', domain: nil, persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to_not have_received(:add) end @@ -34,7 +34,7 @@ describe UniqueUsernameValidator do context 'when remote account' do it 'does not add errors if username is nil' do - account = double(username: nil, domain: 'example.com', persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: nil, domain: 'example.com', persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to_not have_received(:add) end @@ -46,23 +46,29 @@ describe UniqueUsernameValidator do it 'adds an error when the username is already used with ignoring cases' do Fabricate(:account, username: 'ABCdef', domain: 'example.com') - account = double(username: 'abcDEF', domain: 'example.com', persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: 'abcDEF', domain: 'example.com', persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to have_received(:add) end it 'adds an error when the domain is already used with ignoring cases' do Fabricate(:account, username: 'ABCdef', domain: 'example.com') - account = double(username: 'ABCdef', domain: 'EXAMPLE.COM', persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: 'ABCdef', domain: 'EXAMPLE.COM', persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to have_received(:add) end it 'does not add errors when account with the same username and another domain exists' do Fabricate(:account, username: 'abcdef', domain: 'example.com') - account = double(username: 'abcdef', domain: 'example2.com', persisted?: false, errors: double(add: nil)) + account = instance_double(Account, username: 'abcdef', domain: 'example2.com', persisted?: false, errors: activemodel_errors) subject.validate(account) expect(account.errors).to_not have_received(:add) end end + + private + + def activemodel_errors + instance_double(ActiveModel::Errors, add: nil) + end end diff --git a/spec/validators/unreserved_username_validator_spec.rb b/spec/validators/unreserved_username_validator_spec.rb index 3c6f71c590e..0eb5f83683d 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) { double(username: username, errors: errors) } - let(:errors) { double(add: nil) } + describe '#validate' do + context 'when username is nil' do + it 'does not add errors' do + record.username = nil - context '@username.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 '!@username.blank?' do - let(:username) { 'f' } + context 'when PAM is enabled' do + before do + allow(Devise).to receive(:pam_authentication).and_return(true) + end - context '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 '!reserved_username?' 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 966261b5054..4f32b7b3995 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) { double(errors: errors) } - let(:errors) { double(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 'unless 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 'if 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 00000000000..82a1dee6d72 --- /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=