From b990ae6b230840cb7125a7c8d1eafdd7c959bc91 Mon Sep 17 00:00:00 2001
From: zyoshoka <107108195+zyoshoka@users.noreply.github.com>
Date: Tue, 15 Oct 2024 13:37:00 +0900
Subject: [PATCH] test(backend): add federation test (#14582)

* test(backend): add federation test

* fix(ci): install pnpm

* fix(ci): cd

* fix(ci): build entire project

* fix(ci): skip frontend build

* fix(ci): pull submodule when checkout

* chore: show log for debugging

* Revert "chore: show log for debugging"

This reverts commit a930964b8d6ba550c23bce1e7fb45d92eab49ef9.

* fix(ci): build entire project

* chore: omit unused globals

* refactor: use strictEqual and simplify some asserts

* test: follow requests

* refactor: add resolveRemoteNote function

* refactor: refine resolveRemoteUser function

* refactor: cache admin credentials

* refactor: simplify assertion with excluded fields

* refactor: use assert

* test: note

* chore: labeler detect federation

* test: blocking

* test: move

* fix: use appropriate TLD

* chore: shorter purge interval

* fix(ci): change TLD

* refactor: delete trivial comment

* test(user): isCat

* chore: use jest

* chore: omit logs

* chore: add memo

* fix(ci): omit unnecessary build

* test: pinning Note

* fix: build daemon in container

* style: indent

* test(streaming): timeline

* chore: rename

* fix: delete role after test

* refactor: resolve users by uri

* fix: delete antenna after test

* test: api timeline

* test: Note deletion

* refactor: sleep function

* test: notification

* style: indent

* refactor: type-safe host

* docs: update description

* refactor: resolve function params

* fix(block): wrong test name

* fix: invalid type

* fix: longer timeout for fire testing

* test(timeline): hashtag

* test(note): vote delivery

* fix: wrong description

* fix: hashtag channel param type

* refactor: wrap basic cases

* test(timeline): add homeTimeline tests

* fix(timeline): correct wrong case and description

* test(notification): add tests for Note

* refactor(user): wrap profile consistency with describe

* chore(note): add issue link

* test(timeline): add test

* test(user): suspension

* test: emoji

* refactor: fetch admin first

* perf: faster tests

* test(drive): sensitive flag

* test(emoji): add tests

* chore: ignore .config/docker.env

* chore: hard-coded tester IP address

* test(emoji): custom emoji are surrounded by zero width space

* refactor: client and username as property

* test(notification): mute

* fix(notification): correct description

* test(block): mention

* refactor(emoji): addCustomEmoji function

* fix: typo

* test(note): add reaction tests

* test(timeline): Note deletion

* fix: unnecessary ts-expect-error

* refactor: unnecessary fetch mocking

* chore: add TODO comments

* test(user): deletion

* chore: enable --frozen-lockfile

* fix(ci): copying configs

* docs: update CONTRIBUTING.md

* docs: fix typo

* chore: set default sleep duration

* fix(notification): omit flaky tests

* fix(notification): correct type

* test(notification): add api endpoint tests

* chore: remove redundant mute test

* refactor: use param client

* fix: start timer after trigger

* refactor: remove unnecessary any

* chore: shorter timeout for checking if fired

* fix(block): remove outdated comment

* refactor: shorten remote user variable name

* refactor(block): use existing function

* refactor: file upload

* docs: update description

* test(user): ffVisibility

* fix: `/api/signin` -> `/api/signin-flow`

* test: abuse report

* refactor: use existing type

* refactor: extract duplicate configs to template file

* fix: typo

* fix: avoid conflict

* refactor: change container dependency

* perf: start misskey parallelly

* fix: remove dependency

* chore(backend): add typecheck

* test: add check for #14728

* chore: enable eslint check

* perf: don't start linked services when test

* test(note): remote note deletion for moderation

* chore: define config template

* chore: write setup script

* refactor: omit unnecessary conditional

* refactor: clarify scope

* refactor: omit type assertion

* refactor: omit logs

* style

* refactor: redundant promise

* refactor: unnecessary imports

* refactor: use readable error code

* refactor: cache set in signin function

* refactor: optimize import
---
 .github/labeler.yml                           |   2 +-
 .github/workflows/test-federation.yml         |  59 ++
 .gitignore                                    |   2 +-
 CONTRIBUTING.md                               |  46 +-
 packages/backend/eslint.config.js             |   2 +-
 packages/backend/jest.config.fed.cjs          |  13 +
 packages/backend/package.json                 |   6 +-
 .../test-federation/.config/example.conf      |  70 +++
 .../.config/example.default.yml               |  25 +
 .../.config/example.docker.env                |   5 +
 packages/backend/test-federation/.gitignore   |   6 +
 packages/backend/test-federation/README.md    |  24 +
 .../backend/test-federation/compose.a.yml     |  64 ++
 .../backend/test-federation/compose.b.yml     |  64 ++
 .../test-federation/compose.override.yaml     | 117 ++++
 .../backend/test-federation/compose.tpl.yml   | 101 ++++
 packages/backend/test-federation/compose.yml  | 133 +++++
 packages/backend/test-federation/daemon.ts    |  38 ++
 .../backend/test-federation/eslint.config.js  |  21 +
 packages/backend/test-federation/setup.sh     |  35 ++
 .../test-federation/test/abuse-report.test.ts |  52 ++
 .../test-federation/test/block.test.ts        | 224 +++++++
 .../test-federation/test/drive.test.ts        | 175 ++++++
 .../test-federation/test/emoji.test.ts        |  97 +++
 .../backend/test-federation/test/move.test.ts |  52 ++
 .../backend/test-federation/test/note.test.ts | 317 ++++++++++
 .../test-federation/test/notification.test.ts | 107 ++++
 .../test-federation/test/timeline.test.ts     | 328 ++++++++++
 .../backend/test-federation/test/user.test.ts | 560 ++++++++++++++++++
 .../backend/test-federation/test/utils.ts     | 309 ++++++++++
 .../backend/test-federation/tsconfig.json     | 114 ++++
 packages/shared/eslint.config.js              |   7 +
 32 files changed, 3154 insertions(+), 21 deletions(-)
 create mode 100644 .github/workflows/test-federation.yml
 create mode 100644 packages/backend/jest.config.fed.cjs
 create mode 100644 packages/backend/test-federation/.config/example.conf
 create mode 100644 packages/backend/test-federation/.config/example.default.yml
 create mode 100644 packages/backend/test-federation/.config/example.docker.env
 create mode 100644 packages/backend/test-federation/.gitignore
 create mode 100644 packages/backend/test-federation/README.md
 create mode 100644 packages/backend/test-federation/compose.a.yml
 create mode 100644 packages/backend/test-federation/compose.b.yml
 create mode 100644 packages/backend/test-federation/compose.override.yaml
 create mode 100644 packages/backend/test-federation/compose.tpl.yml
 create mode 100644 packages/backend/test-federation/compose.yml
 create mode 100644 packages/backend/test-federation/daemon.ts
 create mode 100644 packages/backend/test-federation/eslint.config.js
 create mode 100644 packages/backend/test-federation/setup.sh
 create mode 100644 packages/backend/test-federation/test/abuse-report.test.ts
 create mode 100644 packages/backend/test-federation/test/block.test.ts
 create mode 100644 packages/backend/test-federation/test/drive.test.ts
 create mode 100644 packages/backend/test-federation/test/emoji.test.ts
 create mode 100644 packages/backend/test-federation/test/move.test.ts
 create mode 100644 packages/backend/test-federation/test/note.test.ts
 create mode 100644 packages/backend/test-federation/test/notification.test.ts
 create mode 100644 packages/backend/test-federation/test/timeline.test.ts
 create mode 100644 packages/backend/test-federation/test/user.test.ts
 create mode 100644 packages/backend/test-federation/test/utils.ts
 create mode 100644 packages/backend/test-federation/tsconfig.json

diff --git a/.github/labeler.yml b/.github/labeler.yml
index a77f73706b..b64d726d65 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -6,7 +6,7 @@
 'packages/backend:test':
 - any:
   - changed-files:
-    - any-glob-to-any-file: ['packages/backend/test/**/*']
+    - any-glob-to-any-file: ['packages/backend/test/**/*', 'packages/backend/test-federation/**/*']
 
 'packages/frontend':
 - any:
diff --git a/.github/workflows/test-federation.yml b/.github/workflows/test-federation.yml
new file mode 100644
index 0000000000..183ddb6f34
--- /dev/null
+++ b/.github/workflows/test-federation.yml
@@ -0,0 +1,59 @@
+name: Test (federation)
+
+on:
+  push:
+    branches:
+      - master
+      - develop
+    paths:
+      - packages/backend/**
+      - packages/misskey-js/**
+      - .github/workflows/test-federation.yml
+  pull_request:
+    paths:
+      - packages/backend/**
+      - packages/misskey-js/**
+      - .github/workflows/test-federation.yml
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node-version: [20.16.0]
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          submodules: true
+      - name: Install pnpm
+        uses: pnpm/action-setup@v4
+      - name: Install FFmpeg
+        uses: FedericoCarboni/setup-ffmpeg@v3
+      - name: Use Node.js ${{ matrix.node-version }}
+        uses: actions/setup-node@v4.0.3
+        with:
+          node-version: ${{ matrix.node-version }}
+          cache: 'pnpm'
+      - name: Build Misskey
+        run: |
+          corepack enable && corepack prepare
+          pnpm i --frozen-lockfile
+          pnpm build
+      - name: Setup
+        run: |
+          cd packages/backend/test-federation
+          bash ./setup.sh
+          sudo chmod 644 ./certificates/*.test.key
+      - name: Start servers
+        # https://github.com/docker/compose/issues/1294#issuecomment-374847206
+        run: |
+          cd packages/backend/test-federation
+          docker compose up -d --scale tester=0
+      - name: Test
+        run: |
+          cd packages/backend/test-federation
+          docker compose run --no-deps tester
+      - name: Stop servers
+        run: |
+          cd packages/backend/test-federation
+          docker compose down
diff --git a/.gitignore b/.gitignore
index b270d5cb3a..5b8a798ba6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,7 +37,7 @@ coverage
 !/.config/docker_example.env
 !/.config/cypress-devcontainer.yml
 docker-compose.yml
-compose.yml
+./compose.yml
 .devcontainer/compose.yml
 !/.devcontainer/compose.yml
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3a4dc7b918..fc72cf42ea 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -181,31 +181,45 @@ MK_DEV_PREFER=backend pnpm dev
 - HMR may not work in some environments such as Windows.
 
 ## Testing
-- Test codes are located in [`/packages/backend/test`](/packages/backend/test).
-
-### Run test
-Create a config file.
+You can run non-backend tests by executing following commands:
+```sh
+pnpm --filter frontend test
+pnpm --filter misskey-js test
 ```
+
+Backend tests require manual preparation of servers. See the next section for more on this.
+
+### Backend
+There are three types of test codes for the backend:
+- Unit tests: [`/packages/backend/test/unit`](/packages/backend/test/unit)
+- Single-server E2E tests: [`/packages/backend/test/e2e`](/packages/backend/test/e2e)
+- Multiple-server E2E tests: [`/packages/backend/test-federation`](/packages/backend/test-federation)
+
+#### Running Unit Tests or Single-server E2E Tests
+1. Create a config file:
+```sh
 cp .github/misskey/test.yml .config/
 ```
-Prepare DB/Redis for testing.
-```
-docker compose -f packages/backend/test/compose.yml up
-```
-Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`.
 
-Run all test.
-```
-pnpm test
+2. Start DB and Redis servers for testing:
+```sh
+docker compose -f packages/backend/test/compose.yml up
 ```
+Instead, you can prepare an empty (data can be erased) DB and edit `.config/test.yml` appropriately.
 
-#### Run specify test
+3. Run all tests:
+```sh
+pnpm --filter backend test     # unit tests
+pnpm --filter backend test:e2e # single-server E2E tests
 ```
-pnpm jest -- foo.ts
+If you want to run a specific test, run as a following command:
+```sh
+pnpm --filter backend test -- packages/backend/test/unit/activitypub.ts
+pnpm --filter backend test:e2e -- packages/backend/test/e2e/nodeinfo.ts
 ```
 
-### e2e tests
-TODO
+#### Running Multiple-server E2E Tests
+See [`/packages/backend/test-federation/README.md`](/packages/backend/test-federation/README.md).
 
 ## Environment Variable
 
diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js
index 4fd9f0cd51..ae7b2baf49 100644
--- a/packages/backend/eslint.config.js
+++ b/packages/backend/eslint.config.js
@@ -11,7 +11,7 @@ export default [
 		languageOptions: {
 			parserOptions: {
 				parser: tsParser,
-				project: ['./tsconfig.json', './test/tsconfig.json'],
+				project: ['./tsconfig.json', './test/tsconfig.json', './test-federation/tsconfig.json'],
 				sourceType: 'module',
 				tsconfigRootDir: import.meta.dirname,
 			},
diff --git a/packages/backend/jest.config.fed.cjs b/packages/backend/jest.config.fed.cjs
new file mode 100644
index 0000000000..fae187bc23
--- /dev/null
+++ b/packages/backend/jest.config.fed.cjs
@@ -0,0 +1,13 @@
+/*
+ * For a detailed explanation regarding each configuration property and type check, visit:
+ * https://jestjs.io/docs/en/configuration.html
+ */
+
+const base = require('./jest.config.cjs');
+
+module.exports = {
+	...base,
+	testMatch: [
+		'<rootDir>/test-federation/test/**/*.test.ts',
+	],
+};
diff --git a/packages/backend/package.json b/packages/backend/package.json
index c6e31797f8..0dd738a1e6 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -19,16 +19,18 @@
 		"watch": "node ./scripts/watch.mjs",
 		"restart": "pnpm build && pnpm start",
 		"dev": "node ./scripts/dev.mjs",
-		"typecheck": "tsc --noEmit && tsc -p test --noEmit",
-		"eslint": "eslint --quiet \"src/**/*.ts\"",
+		"typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
+		"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
 		"lint": "pnpm typecheck && pnpm eslint",
 		"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
 		"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
+		"jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs",
 		"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs",
 		"jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs",
 		"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
 		"test": "pnpm jest",
 		"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
+		"test:fed": "pnpm jest:fed",
 		"test-and-coverage": "pnpm jest-and-coverage",
 		"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
 		"generate-api-json": "node ./scripts/generate_api_json.js"
diff --git a/packages/backend/test-federation/.config/example.conf b/packages/backend/test-federation/.config/example.conf
new file mode 100644
index 0000000000..83d04eb39d
--- /dev/null
+++ b/packages/backend/test-federation/.config/example.conf
@@ -0,0 +1,70 @@
+# based on https://github.com/misskey-dev/misskey-hub/blob/7071f63a1c80ee35c71f0fd8a6d8722c118c7574/src/docs/admin/nginx.md
+
+# For WebSocket
+map $http_upgrade $connection_upgrade {
+	default upgrade;
+	'' close;
+}
+
+proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=cache1:16m max_size=1g inactive=720m use_temp_path=off;
+
+server {
+	listen 80;
+	listen [::]:80;
+	server_name ${HOST};
+
+	# For SSL domain validation
+	root /var/www/html;
+	location /.well-known/acme-challenge/ { allow all; }
+	location /.well-known/pki-validation/ { allow all; }
+	location / { return 301 https://$server_name$request_uri; }
+}
+
+server {
+	listen 443 ssl;
+	listen [::]:443 ssl;
+	http2 on;
+	server_name ${HOST};
+
+	ssl_session_timeout 1d;
+	ssl_session_cache shared:ssl_session_cache:10m;
+	ssl_session_tickets off;
+
+	ssl_trusted_certificate /etc/nginx/certificates/rootCA.crt;
+	ssl_certificate /etc/nginx/certificates/$server_name.crt;
+	ssl_certificate_key /etc/nginx/certificates/$server_name.key;
+
+	# SSL protocol settings
+	ssl_protocols TLSv1.2 TLSv1.3;
+	ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
+	ssl_prefer_server_ciphers off;
+	ssl_stapling on;
+	ssl_stapling_verify on;
+
+	# Change to your upload limit
+	client_max_body_size 80m;
+
+	# Proxy to Node
+	location / {
+		proxy_pass http://misskey.${HOST}:3000;
+		proxy_set_header Host $host;
+		proxy_http_version 1.1;
+		proxy_redirect off;
+
+		# If it's behind another reverse proxy or CDN, remove the following.
+		proxy_set_header X-Real-IP $remote_addr;
+		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+		proxy_set_header X-Forwarded-Proto https;
+
+		# For WebSocket
+		proxy_set_header Upgrade $http_upgrade;
+		proxy_set_header Connection $connection_upgrade;
+
+		# Cache settings
+		proxy_cache cache1;
+		proxy_cache_lock on;
+		proxy_cache_use_stale updating;
+		proxy_force_ranges on;
+		add_header X-Cache $upstream_cache_status;
+	}
+}
diff --git a/packages/backend/test-federation/.config/example.default.yml b/packages/backend/test-federation/.config/example.default.yml
new file mode 100644
index 0000000000..ff1760a5a6
--- /dev/null
+++ b/packages/backend/test-federation/.config/example.default.yml
@@ -0,0 +1,25 @@
+url: https://${HOST}/
+port: 3000
+db:
+  host: db.${HOST}
+  port: 5432
+  db: misskey
+  user: postgres
+  pass: postgres
+dbReplications: false
+redis:
+  host: redis.test
+  port: 6379
+id: 'aidx'
+proxyBypassHosts:
+  - api.deepl.com
+  - api-free.deepl.com
+  - www.recaptcha.net
+  - hcaptcha.com
+  - challenges.cloudflare.com
+proxyRemoteFiles: true
+signToActivityPubGet: true
+allowedPrivateNetworks: [
+  '127.0.0.1/32',
+  '172.20.0.0/16'
+]
diff --git a/packages/backend/test-federation/.config/example.docker.env b/packages/backend/test-federation/.config/example.docker.env
new file mode 100644
index 0000000000..a8af7cce49
--- /dev/null
+++ b/packages/backend/test-federation/.config/example.docker.env
@@ -0,0 +1,5 @@
+NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt
+POSTGRES_DB=misskey
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=postgres
+MK_VERBOSE=true
diff --git a/packages/backend/test-federation/.gitignore b/packages/backend/test-federation/.gitignore
new file mode 100644
index 0000000000..e00f952cb5
--- /dev/null
+++ b/packages/backend/test-federation/.gitignore
@@ -0,0 +1,6 @@
+certificates
+volumes
+.env
+docker.env
+*.test.conf
+*.test.default.yml
diff --git a/packages/backend/test-federation/README.md b/packages/backend/test-federation/README.md
new file mode 100644
index 0000000000..967d51f085
--- /dev/null
+++ b/packages/backend/test-federation/README.md
@@ -0,0 +1,24 @@
+## test-federation
+Test federation between two Misskey servers: `a.test` and `b.test`.
+
+Before testing, you need to build the entire project, and change working directory to here:
+```sh
+pnpm build
+cd packages/backend/test-federation
+```
+
+First, you need to start servers by executing following commands:
+```sh
+bash ./setup.sh
+docker compose up --scale tester=0
+```
+
+Then you can run all tests by a following command:
+```sh
+docker compose run --no-deps --rm tester
+```
+
+For testing a specific file, run a following command:
+```sh
+docker compose run --no-deps --rm tester -- pnpm -F backend test:fed packages/backend/test-federation/test/user.test.ts
+```
diff --git a/packages/backend/test-federation/compose.a.yml b/packages/backend/test-federation/compose.a.yml
new file mode 100644
index 0000000000..6a305b404c
--- /dev/null
+++ b/packages/backend/test-federation/compose.a.yml
@@ -0,0 +1,64 @@
+services:
+  a.test:
+    extends:
+      file: ./compose.tpl.yml
+      service: nginx
+    depends_on:
+      misskey.a.test:
+        condition: service_healthy
+    networks:
+      - internal_network_a
+    volumes:
+      - type: bind
+        source: ./.config/a.test.conf
+        target: /etc/nginx/conf.d/a.test.conf
+        read_only: true
+      - type: bind
+        source: ./certificates/a.test.crt
+        target: /etc/nginx/certificates/a.test.crt
+        read_only: true
+      - type: bind
+        source: ./certificates/a.test.key
+        target: /etc/nginx/certificates/a.test.key
+        read_only: true
+
+  misskey.a.test:
+    extends:
+      file: ./compose.tpl.yml
+      service: misskey
+    depends_on:
+      db.a.test:
+        condition: service_healthy
+      redis.test:
+        condition: service_healthy
+      setup:
+        condition: service_completed_successfully
+    networks:
+      - internal_network_a
+    volumes:
+      - type: bind
+        source: ./.config/a.test.default.yml
+        target: /misskey/.config/default.yml
+        read_only: true
+
+  db.a.test:
+    extends:
+      file: ./compose.tpl.yml
+      service: db
+    networks:
+      - internal_network_a
+    volumes:
+      - type: bind
+        source: ./volumes/db.a
+        target: /var/lib/postgresql/data
+        bind:
+          create_host_path: true
+
+networks:
+  internal_network_a:
+    internal: true
+    driver: bridge
+    ipam:
+      config:
+        - subnet: 172.21.0.0/16
+          ip_range: 172.21.0.0/24
diff --git a/packages/backend/test-federation/compose.b.yml b/packages/backend/test-federation/compose.b.yml
new file mode 100644
index 0000000000..1158b53bae
--- /dev/null
+++ b/packages/backend/test-federation/compose.b.yml
@@ -0,0 +1,64 @@
+services:
+  b.test:
+    extends:
+      file: ./compose.tpl.yml
+      service: nginx
+    depends_on:
+      misskey.b.test:
+        condition: service_healthy
+    networks:
+      - internal_network_b
+    volumes:
+      - type: bind
+        source: ./.config/b.test.conf
+        target: /etc/nginx/conf.d/b.test.conf
+        read_only: true
+      - type: bind
+        source: ./certificates/b.test.crt
+        target: /etc/nginx/certificates/b.test.crt
+        read_only: true
+      - type: bind
+        source: ./certificates/b.test.key
+        target: /etc/nginx/certificates/b.test.key
+        read_only: true
+
+  misskey.b.test:
+    extends:
+      file: ./compose.tpl.yml
+      service: misskey
+    depends_on:
+      db.b.test:
+        condition: service_healthy
+      redis.test:
+        condition: service_healthy
+      setup:
+        condition: service_completed_successfully
+    networks:
+      - internal_network_b
+    volumes:
+      - type: bind
+        source: ./.config/b.test.default.yml
+        target: /misskey/.config/default.yml
+        read_only: true
+
+  db.b.test:
+    extends:
+      file: ./compose.tpl.yml
+      service: db
+    networks:
+      - internal_network_b
+    volumes:
+      - type: bind
+        source: ./volumes/db.b
+        target: /var/lib/postgresql/data
+        bind:
+          create_host_path: true
+
+networks:
+  internal_network_b:
+    internal: true
+    driver: bridge
+    ipam:
+      config:
+        - subnet: 172.22.0.0/16
+          ip_range: 172.22.0.0/24
diff --git a/packages/backend/test-federation/compose.override.yaml b/packages/backend/test-federation/compose.override.yaml
new file mode 100644
index 0000000000..60a7631ab5
--- /dev/null
+++ b/packages/backend/test-federation/compose.override.yaml
@@ -0,0 +1,117 @@
+services:
+  setup:
+    volumes:
+      - type: volume
+        source: node_modules
+        target: /misskey/node_modules
+      - type: volume
+        source: node_modules_backend
+        target: /misskey/packages/backend/node_modules
+      - type: volume
+        source: node_modules_misskey-js
+        target: /misskey/packages/misskey-js/node_modules
+      - type: volume
+        source: node_modules_misskey-reversi
+        target: /misskey/packages/misskey-reversi/node_modules
+
+  tester:
+    networks:
+      external_network:
+      internal_network:
+        ipv4_address: 172.20.1.1
+    volumes:
+      - type: volume
+        source: node_modules_dev
+        target: /misskey/node_modules
+      - type: volume
+        source: node_modules_backend_dev
+        target: /misskey/packages/backend/node_modules
+      - type: volume
+        source: node_modules_misskey-js_dev
+        target: /misskey/packages/misskey-js/node_modules
+
+  daemon:
+    networks:
+      - external_network
+      - internal_network_a
+      - internal_network_b
+    volumes:
+      - type: volume
+        source: node_modules_dev
+        target: /misskey/node_modules
+      - type: volume
+        source: node_modules_backend_dev
+        target: /misskey/packages/backend/node_modules
+
+  redis.test:
+    networks:
+      - internal_network_a
+      - internal_network_b
+
+  a.test:
+    networks:
+      - internal_network
+
+  misskey.a.test:
+    networks:
+      - external_network
+      - internal_network
+    volumes:
+      - type: volume
+        source: node_modules
+        target: /misskey/node_modules
+      - type: volume
+        source: node_modules_backend
+        target: /misskey/packages/backend/node_modules
+      - type: volume
+        source: node_modules_misskey-js
+        target: /misskey/packages/misskey-js/node_modules
+      - type: volume
+        source: node_modules_misskey-reversi
+        target: /misskey/packages/misskey-reversi/node_modules
+
+  b.test:
+    networks:
+      - internal_network
+
+  misskey.b.test:
+    networks:
+      - external_network
+      - internal_network
+    volumes:
+      - type: volume
+        source: node_modules
+        target: /misskey/node_modules
+      - type: volume
+        source: node_modules_backend
+        target: /misskey/packages/backend/node_modules
+      - type: volume
+        source: node_modules_misskey-js
+        target: /misskey/packages/misskey-js/node_modules
+      - type: volume
+        source: node_modules_misskey-reversi
+        target: /misskey/packages/misskey-reversi/node_modules
+
+networks:
+  external_network:
+    driver: bridge
+    ipam:
+      config:
+        - subnet: 172.23.0.0/16
+          ip_range: 172.23.0.0/24
+  internal_network:
+    internal: true
+    driver: bridge
+    ipam:
+      config:
+        - subnet: 172.20.0.0/16
+          ip_range: 172.20.0.0/24
+
+volumes:
+  node_modules:
+  node_modules_dev:
+  node_modules_backend:
+  node_modules_backend_dev:
+  node_modules_misskey-js:
+  node_modules_misskey-js_dev:
+  node_modules_misskey-reversi:
diff --git a/packages/backend/test-federation/compose.tpl.yml b/packages/backend/test-federation/compose.tpl.yml
new file mode 100644
index 0000000000..8c38f16919
--- /dev/null
+++ b/packages/backend/test-federation/compose.tpl.yml
@@ -0,0 +1,101 @@
+services:
+  nginx:
+    image: nginx:1.27
+    volumes:
+      - type: bind
+        source: ./certificates/rootCA.crt
+        target: /etc/nginx/certificates/rootCA.crt
+        read_only: true
+    healthcheck:
+      test: service nginx status
+      interval: 5s
+      retries: 20
+
+  misskey:
+    image: node:20
+    env_file:
+      - ./.config/docker.env
+    environment:
+      - NODE_ENV=production
+    volumes:
+      - type: bind
+        source: ../../../built
+        target: /misskey/built
+        read_only: true
+      - type: bind
+        source: ../assets
+        target: /misskey/packages/backend/assets
+        read_only: true
+      - type: bind
+        source: ../built
+        target: /misskey/packages/backend/built
+        read_only: true
+      - type: bind
+        source: ../migration
+        target: /misskey/packages/backend/migration
+        read_only: true
+      - type: bind
+        source: ../ormconfig.js
+        target: /misskey/packages/backend/ormconfig.js
+        read_only: true
+      - type: bind
+        source: ../package.json
+        target: /misskey/packages/backend/package.json
+        read_only: true
+      - type: bind
+        source: ../../misskey-js/built
+        target: /misskey/packages/misskey-js/built
+        read_only: true
+      - type: bind
+        source: ../../misskey-js/package.json
+        target: /misskey/packages/misskey-js/package.json
+        read_only: true
+      - type: bind
+        source: ../../misskey-reversi/built
+        target: /misskey/packages/misskey-reversi/built
+        read_only: true
+      - type: bind
+        source: ../../misskey-reversi/package.json
+        target: /misskey/packages/misskey-reversi/package.json
+        read_only: true
+      - type: bind
+        source: ../../../healthcheck.sh
+        target: /misskey/healthcheck.sh
+        read_only: true
+      - type: bind
+        source: ../../../package.json
+        target: /misskey/package.json
+        read_only: true
+      - type: bind
+        source: ../../../pnpm-lock.yaml
+        target: /misskey/pnpm-lock.yaml
+        read_only: true
+      - type: bind
+        source: ../../../pnpm-workspace.yaml
+        target: /misskey/pnpm-workspace.yaml
+        read_only: true
+      - type: bind
+        source: ./certificates/rootCA.crt
+        target: /usr/local/share/ca-certificates/rootCA.crt
+        read_only: true
+    working_dir: /misskey
+    command: >
+      bash -c "
+        corepack enable && corepack prepare
+        pnpm -F backend migrate
+        pnpm -F backend start
+      "
+    healthcheck:
+      test: bash /misskey/healthcheck.sh
+      interval: 5s
+      retries: 20
+
+  db:
+    image: postgres:15-alpine
+    env_file:
+      - ./.config/docker.env
+    volumes:
+    healthcheck:
+      test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
+      interval: 5s
+      retries: 20
diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml
new file mode 100644
index 0000000000..62d7e977c0
--- /dev/null
+++ b/packages/backend/test-federation/compose.yml
@@ -0,0 +1,133 @@
+include:
+  - ./compose.a.yml
+  - ./compose.b.yml
+
+services:
+  setup:
+    extends:
+      file: ./compose.tpl.yml
+      service: misskey
+    command: >
+      bash -c "
+        corepack enable && corepack prepare
+        pnpm -F backend i
+        pnpm -F misskey-js i
+        pnpm -F misskey-reversi i
+      "
+
+  tester:
+    image: node:20
+    depends_on:
+      a.test:
+        condition: service_healthy
+      b.test:
+        condition: service_healthy
+    environment:
+      - NODE_ENV=development
+      - NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt
+    volumes:
+      - type: bind
+        source: ../package.json
+        target: /misskey/packages/backend/package.json
+        read_only: true
+      - type: bind
+        source: ../test/resources
+        target: /misskey/packages/backend/test/resources
+        read_only: true
+      - type: bind
+        source: ./test
+        target: /misskey/packages/backend/test-federation/test
+        read_only: true
+      - type: bind
+        source: ../jest.config.cjs
+        target: /misskey/packages/backend/jest.config.cjs
+        read_only: true
+      - type: bind
+        source: ../jest.config.fed.cjs
+        target: /misskey/packages/backend/jest.config.fed.cjs
+        read_only: true
+      - type: bind
+        source: ../../misskey-js/built
+        target: /misskey/packages/misskey-js/built
+        read_only: true
+      - type: bind
+        source: ../../misskey-js/package.json
+        target: /misskey/packages/misskey-js/package.json
+        read_only: true
+      - type: bind
+        source: ../../../package.json
+        target: /misskey/package.json
+        read_only: true
+      - type: bind
+        source: ../../../pnpm-lock.yaml
+        target: /misskey/pnpm-lock.yaml
+        read_only: true
+      - type: bind
+        source: ../../../pnpm-workspace.yaml
+        target: /misskey/pnpm-workspace.yaml
+        read_only: true
+      - type: bind
+        source: ./certificates/rootCA.crt
+        target: /usr/local/share/ca-certificates/rootCA.crt
+        read_only: true
+    working_dir: /misskey
+    entrypoint: >
+      bash -c '
+        corepack enable && corepack prepare
+        pnpm -F misskey-js i --frozen-lockfile
+        pnpm -F backend i --frozen-lockfile
+        exec "$0" "$@"
+      '
+    command: pnpm -F backend test:fed
+
+  daemon:
+    image: node:20
+    depends_on:
+      redis.test:
+        condition: service_healthy
+    volumes:
+      - type: bind
+        source: ../package.json
+        target: /misskey/packages/backend/package.json
+        read_only: true
+      - type: bind
+        source: ./daemon.ts
+        target: /misskey/packages/backend/test-federation/daemon.ts
+        read_only: true
+      - type: bind
+        source: ./tsconfig.json
+        target: /misskey/packages/backend/test-federation/tsconfig.json
+        read_only: true
+      - type: bind
+        source: ../../../package.json
+        target: /misskey/package.json
+        read_only: true
+      - type: bind
+        source: ../../../pnpm-lock.yaml
+        target: /misskey/pnpm-lock.yaml
+        read_only: true
+      - type: bind
+        source: ../../../pnpm-workspace.yaml
+        target: /misskey/pnpm-workspace.yaml
+        read_only: true
+    working_dir: /misskey
+    command: >
+      bash -c "
+        corepack enable && corepack prepare
+        pnpm -F backend i --frozen-lockfile
+        pnpm exec tsc -p ./packages/backend/test-federation
+        node ./packages/backend/test-federation/built/daemon.js
+      "
+
+  redis.test:
+    image: redis:7-alpine
+    volumes:
+      - type: bind
+        source: ./volumes/redis
+        target: /data
+        bind:
+          create_host_path: true
+    healthcheck:
+      test: redis-cli ping
+      interval: 5s
+      retries: 20
diff --git a/packages/backend/test-federation/daemon.ts b/packages/backend/test-federation/daemon.ts
new file mode 100644
index 0000000000..46b6963c79
--- /dev/null
+++ b/packages/backend/test-federation/daemon.ts
@@ -0,0 +1,38 @@
+import IPCIDR from 'ip-cidr';
+import { Redis } from 'ioredis';
+
+const TESTER_IP_ADDRESS = '172.20.1.1';
+
+/**
+ * This should be same as {@link file://./../src/misc/get-ip-hash.ts}.
+ */
+function getIpHash(ip: string) {
+	const prefix = IPCIDR.createAddress(ip).mask(64);
+	return `ip-${BigInt('0b' + prefix).toString(36)}`;
+}
+
+/**
+ * This prevents hitting rate limit when login.
+ */
+export async function purgeLimit(host: string, client: Redis) {
+	const ipHash = getIpHash(TESTER_IP_ADDRESS);
+	const key = `${host}:limit:${ipHash}:signin`;
+	const res = await client.zrange(key, 0, -1);
+	if (res.length !== 0) {
+		console.log(`${key} - ${JSON.stringify(res)}`);
+		await client.del(key);
+	}
+}
+
+console.log('Daemon started running');
+
+{
+	const redisClient = new Redis({
+		host: 'redis.test',
+	});
+
+	setInterval(() => {
+		purgeLimit('a.test', redisClient);
+		purgeLimit('b.test', redisClient);
+	}, 200);
+}
diff --git a/packages/backend/test-federation/eslint.config.js b/packages/backend/test-federation/eslint.config.js
new file mode 100644
index 0000000000..e3bcf4c0fe
--- /dev/null
+++ b/packages/backend/test-federation/eslint.config.js
@@ -0,0 +1,21 @@
+import globals from 'globals';
+import tsParser from '@typescript-eslint/parser';
+import sharedConfig from '../../shared/eslint.config.js';
+
+export default [
+	...sharedConfig,
+	{
+		files: ['**/*.ts', '**/*.tsx'],
+		languageOptions: {
+			globals: {
+				...globals.node,
+			},
+			parserOptions: {
+				parser: tsParser,
+				project: ['./tsconfig.json'],
+				sourceType: 'module',
+				tsconfigRootDir: import.meta.dirname,
+			},
+		},
+	},
+];
diff --git a/packages/backend/test-federation/setup.sh b/packages/backend/test-federation/setup.sh
new file mode 100644
index 0000000000..1bc3a2a87c
--- /dev/null
+++ b/packages/backend/test-federation/setup.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+mkdir certificates
+
+# rootCA
+openssl genrsa -des3 \
+  -passout pass:rootCA \
+  -out certificates/rootCA.key 4096
+openssl req -x509 -new -nodes -batch \
+  -key certificates/rootCA.key \
+  -sha256 \
+  -days 1024 \
+  -passin pass:rootCA \
+  -out certificates/rootCA.crt
+
+# domain
+function generate {
+  openssl req -new -newkey rsa:2048 -sha256 -nodes \
+    -keyout certificates/$1.key \
+    -subj "/CN=$1/emailAddress=admin@$1/C=JP/ST=/L=/O=Misskey Tester/OU=Some Unit" \
+    -out certificates/$1.csr
+  openssl x509 -req -sha256 \
+    -in certificates/$1.csr \
+    -CA certificates/rootCA.crt \
+    -CAkey certificates/rootCA.key \
+    -CAcreateserial \
+    -passin pass:rootCA \
+    -out certificates/$1.crt \
+    -days 500
+  if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi
+  if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi
+  if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.default.yml > .config/$1.default.yml; fi
+}
+
+generate a.test
+generate b.test
diff --git a/packages/backend/test-federation/test/abuse-report.test.ts b/packages/backend/test-federation/test/abuse-report.test.ts
new file mode 100644
index 0000000000..b54d6222b4
--- /dev/null
+++ b/packages/backend/test-federation/test/abuse-report.test.ts
@@ -0,0 +1,52 @@
+import { rejects, strictEqual } from 'node:assert';
+import * as Misskey from 'misskey-js';
+import { createAccount, createModerator, resolveRemoteUser, sleep, type LoginUser } from './utils.js';
+
+describe('Abuse report', () => {
+	describe('Forwarding report', () => {
+		let alice: LoginUser, bob: LoginUser, aModerator: LoginUser, bModerator: LoginUser;
+		let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+		beforeAll(async () => {
+			[alice, bob] = await Promise.all([
+				createAccount('a.test'),
+				createAccount('b.test'),
+			]);
+
+			[aModerator, bModerator] = await Promise.all([
+				createModerator('a.test'),
+				createModerator('b.test'),
+			]);
+
+			[bobInA, aliceInB] = await Promise.all([
+				resolveRemoteUser('b.test', bob.id, alice),
+				resolveRemoteUser('a.test', alice.id, bob),
+			]);
+		});
+
+		test('Alice reports Bob, moderator in A forwards it, and B moderator receives it', async () => {
+			const comment = crypto.randomUUID();
+			await alice.client.request('users/report-abuse', { userId: bobInA.id, comment });
+			const reports = await aModerator.client.request('admin/abuse-user-reports', {});
+			const report = reports.filter(report => report.comment === comment)[0];
+			await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id });
+			await sleep();
+
+			const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {});
+			const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0];
+			// NOTE: reporter is not Alice, and is not moderator in A
+			strictEqual(reportInB.reporter.url, 'https://a.test/@instance.actor');
+			strictEqual(reportInB.targetUserId, bob.id);
+
+			// NOTE: cannot forward multiple times
+			await rejects(
+				async () => await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id }),
+				(err: any) => {
+					strictEqual(err.code, 'INTERNAL_ERROR');
+					strictEqual(err.info.e.message, 'The report has already been forwarded.');
+					return true;
+				},
+			);
+		});
+	});
+});
diff --git a/packages/backend/test-federation/test/block.test.ts b/packages/backend/test-federation/test/block.test.ts
new file mode 100644
index 0000000000..ef910eeaea
--- /dev/null
+++ b/packages/backend/test-federation/test/block.test.ts
@@ -0,0 +1,224 @@
+import { deepStrictEqual, rejects, strictEqual } from 'node:assert';
+import * as Misskey from 'misskey-js';
+import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
+
+describe('Block', () => {
+	describe('Check follow', () => {
+		let alice: LoginUser, bob: LoginUser;
+		let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+		beforeAll(async () => {
+			[alice, bob] = await Promise.all([
+				createAccount('a.test'),
+				createAccount('b.test'),
+			]);
+
+			[bobInA, aliceInB] = await Promise.all([
+				resolveRemoteUser('b.test', bob.id, alice),
+				resolveRemoteUser('a.test', alice.id, bob),
+			]);
+		});
+
+		test('Cannot follow if blocked', async () => {
+			await alice.client.request('blocking/create', { userId: bobInA.id });
+			await sleep();
+			await rejects(
+				async () => await bob.client.request('following/create', { userId: aliceInB.id }),
+				(err: any) => {
+					strictEqual(err.code, 'BLOCKED');
+					return true;
+				},
+			);
+
+			const following = await bob.client.request('users/following', { userId: bob.id });
+			strictEqual(following.length, 0);
+			const followers = await alice.client.request('users/followers', { userId: alice.id });
+			strictEqual(followers.length, 0);
+		});
+
+		// FIXME: this is invalid case
+		test('Cannot follow even if unblocked', async () => {
+			// unblock here
+			await alice.client.request('blocking/delete', { userId: bobInA.id });
+			await sleep();
+
+			// TODO: why still being blocked?
+			await rejects(
+				async () => await bob.client.request('following/create', { userId: aliceInB.id }),
+				(err: any) => {
+					strictEqual(err.code, 'BLOCKED');
+					return true;
+				},
+			);
+		});
+
+		test.skip('Can follow if unblocked', async () => {
+			await alice.client.request('blocking/delete', { userId: bobInA.id });
+			await sleep();
+
+			await bob.client.request('following/create', { userId: aliceInB.id });
+			await sleep();
+
+			const following = await bob.client.request('users/following', { userId: bob.id });
+			strictEqual(following.length, 1);
+			const followers = await alice.client.request('users/followers', { userId: alice.id });
+			strictEqual(followers.length, 1);
+		});
+
+		test.skip('Remove follower when block them', async () => {
+			test('before block', async () => {
+				const following = await bob.client.request('users/following', { userId: bob.id });
+				strictEqual(following.length, 1);
+				const followers = await alice.client.request('users/followers', { userId: alice.id });
+				strictEqual(followers.length, 1);
+			});
+
+			await alice.client.request('blocking/create', { userId: bobInA.id });
+			await sleep();
+
+			test('after block', async () => {
+				const following = await bob.client.request('users/following', { userId: bob.id });
+				strictEqual(following.length, 0);
+				const followers = await alice.client.request('users/followers', { userId: alice.id });
+				strictEqual(followers.length, 0);
+			});
+		});
+	});
+
+	describe('Check reply', () => {
+		let alice: LoginUser, bob: LoginUser;
+		let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+		beforeAll(async () => {
+			[alice, bob] = await Promise.all([
+				createAccount('a.test'),
+				createAccount('b.test'),
+			]);
+
+			[bobInA, aliceInB] = await Promise.all([
+				resolveRemoteUser('b.test', bob.id, alice),
+				resolveRemoteUser('a.test', alice.id, bob),
+			]);
+		});
+
+		test('Cannot reply if blocked', async () => {
+			await alice.client.request('blocking/create', { userId: bobInA.id });
+			await sleep();
+
+			const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+			const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
+			await rejects(
+				async () => await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id }),
+				(err: any) => {
+					strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
+					return true;
+				},
+			);
+		});
+
+		test('Can reply if unblocked', async () => {
+			await alice.client.request('blocking/delete', { userId: bobInA.id });
+			await sleep();
+
+			const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+			const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
+			const reply = (await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id })).createdNote;
+
+			await resolveRemoteNote('b.test', reply.id, alice);
+		});
+	});
+
+	describe('Check reaction', () => {
+		let alice: LoginUser, bob: LoginUser;
+		let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+		beforeAll(async () => {
+			[alice, bob] = await Promise.all([
+				createAccount('a.test'),
+				createAccount('b.test'),
+			]);
+
+			[bobInA, aliceInB] = await Promise.all([
+				resolveRemoteUser('b.test', bob.id, alice),
+				resolveRemoteUser('a.test', alice.id, bob),
+			]);
+		});
+
+		test('Cannot reaction if blocked', async () => {
+			await alice.client.request('blocking/create', { userId: bobInA.id });
+			await sleep();
+
+			const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+			const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
+			await rejects(
+				async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }),
+				(err: any) => {
+					strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
+					return true;
+				},
+			);
+		});
+
+		// FIXME: this is invalid case
+		test('Cannot reaction even if unblocked', async () => {
+			// unblock here
+			await alice.client.request('blocking/delete', { userId: bobInA.id });
+			await sleep();
+
+			const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+			const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
+
+			// TODO: why still being blocked?
+			await rejects(
+				async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }),
+				(err: any) => {
+					strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
+					return true;
+				},
+			);
+		});
+
+		test.skip('Can reaction if unblocked', async () => {
+			await alice.client.request('blocking/delete', { userId: bobInA.id });
+			await sleep();
+
+			const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+			const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
+			await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' });
+
+			const _note = await alice.client.request('notes/show', { noteId: note.id });
+			deepStrictEqual(_note.reactions, { '😅': 1 });
+		});
+	});
+
+	describe('Check mention', () => {
+		let alice: LoginUser, bob: LoginUser;
+		let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+		beforeAll(async () => {
+			[alice, bob] = await Promise.all([
+				createAccount('a.test'),
+				createAccount('b.test'),
+			]);
+
+			[bobInA, aliceInB] = await Promise.all([
+				resolveRemoteUser('b.test', bob.id, alice),
+				resolveRemoteUser('a.test', alice.id, bob),
+			]);
+		});
+
+		/** NOTE: You should mute the target to stop receiving notifications */
+		test('Can mention and notified even if blocked', async () => {
+			await alice.client.request('blocking/create', { userId: bobInA.id });
+			await sleep();
+
+			const text = `@${alice.username}@a.test plz unblock me!`;
+			await assertNotificationReceived(
+				'a.test', alice,
+				async () => await bob.client.request('notes/create', { text }),
+				notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text,
+				true,
+			);
+		});
+	});
+});
diff --git a/packages/backend/test-federation/test/drive.test.ts b/packages/backend/test-federation/test/drive.test.ts
new file mode 100644
index 0000000000..f755183b4d
--- /dev/null
+++ b/packages/backend/test-federation/test/drive.test.ts
@@ -0,0 +1,175 @@
+import assert, { strictEqual } from 'node:assert';
+import * as Misskey from 'misskey-js';
+import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
+
+const bAdmin = await fetchAdmin('b.test');
+
+describe('Drive', () => {
+	describe('Upload image in a.test and resolve from b.test', () => {
+		let uploader: LoginUser;
+
+		beforeAll(async () => {
+			uploader = await createAccount('a.test');
+		});
+
+		let image: Misskey.entities.DriveFile, imageInB: Misskey.entities.DriveFile;
+
+		describe('Upload', () => {
+			beforeAll(async () => {
+				image = await uploadFile('a.test', uploader);
+				const noteWithImage = (await uploader.client.request('notes/create', { fileIds: [image.id] })).createdNote;
+				const noteInB = await resolveRemoteNote('a.test', noteWithImage.id, bAdmin);
+				assert(noteInB.files != null);
+				strictEqual(noteInB.files.length, 1);
+				imageInB = noteInB.files[0];
+			});
+
+			test('Check consistency of DriveFile', () => {
+				// console.log(`a.test: ${JSON.stringify(image, null, '\t')}`);
+				// console.log(`b.test: ${JSON.stringify(imageInB, null, '\t')}`);
+
+				deepStrictEqualWithExcludedFields(image, imageInB, [
+					'id',
+					'createdAt',
+					'size',
+					'url',
+					'thumbnailUrl',
+					'userId',
+				]);
+			});
+		});
+
+		let updatedImage: Misskey.entities.DriveFile, updatedImageInB: Misskey.entities.DriveFile;
+
+		describe('Update', () => {
+			beforeAll(async () => {
+				updatedImage = await uploader.client.request('drive/files/update', {
+					fileId: image.id,
+					name: 'updated_192.jpg',
+					isSensitive: true,
+				});
+
+				updatedImageInB = await bAdmin.client.request('drive/files/show', {
+					fileId: imageInB.id,
+				});
+			});
+
+			test('Check consistency', () => {
+				// console.log(`a.test: ${JSON.stringify(updatedImage, null, '\t')}`);
+				// console.log(`b.test: ${JSON.stringify(updatedImageInB, null, '\t')}`);
+
+				// FIXME: not updated with `drive/files/update`
+				strictEqual(updatedImage.isSensitive, true);
+				strictEqual(updatedImage.name, 'updated_192.jpg');
+				strictEqual(updatedImageInB.isSensitive, false);
+				strictEqual(updatedImageInB.name, '192.jpg');
+			});
+		});
+
+		let reupdatedImageInB: Misskey.entities.DriveFile;
+
+		describe('Re-update with attaching to Note', () => {
+			beforeAll(async () => {
+				const noteWithUpdatedImage = (await uploader.client.request('notes/create', { fileIds: [updatedImage.id] })).createdNote;
+				const noteWithUpdatedImageInB = await resolveRemoteNote('a.test', noteWithUpdatedImage.id, bAdmin);
+				assert(noteWithUpdatedImageInB.files != null);
+				strictEqual(noteWithUpdatedImageInB.files.length, 1);
+				reupdatedImageInB = noteWithUpdatedImageInB.files[0];
+			});
+
+			test('Check consistency', () => {
+				// console.log(`b.test: ${JSON.stringify(reupdatedImageInB, null, '\t')}`);
+
+				// `isSensitive` is updated
+				strictEqual(reupdatedImageInB.isSensitive, true);
+				// FIXME: but `name` is not updated
+				strictEqual(reupdatedImageInB.name, '192.jpg');
+			});
+		});
+	});
+
+	describe('Sensitive flag', () => {
+		describe('isSensitive is federated in delivering to followers', () => {
+			let alice: LoginUser, bob: LoginUser;
+			let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+			beforeAll(async () => {
+				[alice, bob] = await Promise.all([
+					createAccount('a.test'),
+					createAccount('b.test'),
+				]);
+
+				[bobInA, aliceInB] = await Promise.all([
+					resolveRemoteUser('b.test', bob.id, alice),
+					resolveRemoteUser('a.test', alice.id, bob),
+				]);
+
+				await bob.client.request('following/create', { userId: aliceInB.id });
+				await sleep();
+			});
+
+			test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
+				const file = await uploadFile('a.test', alice);
+				await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
+				await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] });
+				await sleep();
+
+				const notes = await bob.client.request('notes/timeline', {});
+				strictEqual(notes.length, 1);
+				const noteInB = notes[0];
+				assert(noteInB.files != null);
+				strictEqual(noteInB.files.length, 1);
+				strictEqual(noteInB.files[0].isSensitive, true);
+			});
+		});
+
+		describe('isSensitive is federated in resolving', () => {
+			let alice: LoginUser, bob: LoginUser;
+
+			beforeAll(async () => {
+				[alice, bob] = await Promise.all([
+					createAccount('a.test'),
+					createAccount('b.test'),
+				]);
+			});
+
+			test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
+				const file = await uploadFile('a.test', alice);
+				await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
+				const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] })).createdNote;
+
+				const noteInB = await resolveRemoteNote('a.test', note.id, bob);
+				assert(noteInB.files != null);
+				strictEqual(noteInB.files.length, 1);
+				strictEqual(noteInB.files[0].isSensitive, true);
+			});
+		});
+
+		/** @see https://github.com/misskey-dev/misskey/issues/12208 */
+		describe('isSensitive is federated in replying', () => {
+			let alice: LoginUser, bob: LoginUser;
+
+			beforeAll(async () => {
+				[alice, bob] = await Promise.all([
+					createAccount('a.test'),
+					createAccount('b.test'),
+				]);
+			});
+
+			test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
+				const bobNote = (await bob.client.request('notes/create', { text: 'I\'m Bob' })).createdNote;
+
+				const file = await uploadFile('a.test', alice);
+				await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
+				const bobNoteInA = await resolveRemoteNote('b.test', bobNote.id, alice);
+				const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id], replyId: bobNoteInA.id })).createdNote;
+				await sleep();
+
+				const noteInB = await resolveRemoteNote('a.test', note.id, bob);
+				assert(noteInB.files != null);
+				strictEqual(noteInB.files.length, 1);
+				strictEqual(noteInB.files[0].isSensitive, true);
+			});
+		});
+	});
+});
diff --git a/packages/backend/test-federation/test/emoji.test.ts b/packages/backend/test-federation/test/emoji.test.ts
new file mode 100644
index 0000000000..3119ca6e4d
--- /dev/null
+++ b/packages/backend/test-federation/test/emoji.test.ts
@@ -0,0 +1,97 @@
+import assert, { deepStrictEqual, strictEqual } from 'assert';
+import * as Misskey from 'misskey-js';
+import { addCustomEmoji, createAccount, type LoginUser, resolveRemoteUser, sleep } from './utils.js';
+
+describe('Emoji', () => {
+	let alice: LoginUser, bob: LoginUser;
+	let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+	beforeAll(async () => {
+		[alice, bob] = await Promise.all([
+			createAccount('a.test'),
+			createAccount('b.test'),
+		]);
+
+		[bobInA, aliceInB] = await Promise.all([
+			resolveRemoteUser('b.test', bob.id, alice),
+			resolveRemoteUser('a.test', alice.id, bob),
+		]);
+
+		await bob.client.request('following/create', { userId: aliceInB.id });
+		await sleep();
+	});
+
+	test('Custom emoji are delivered with Note delivery', async () => {
+		const emoji = await addCustomEmoji('a.test');
+		await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
+		await sleep();
+
+		const notes = await bob.client.request('notes/timeline', {});
+		const noteInB = notes[0];
+
+		strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
+		assert(noteInB.emojis != null);
+		assert(emoji.name in noteInB.emojis);
+		strictEqual(noteInB.emojis[emoji.name], emoji.url);
+	});
+
+	test('Custom emoji are delivered with Reaction delivery', async () => {
+		const emoji = await addCustomEmoji('a.test');
+		const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+		await sleep();
+
+		await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
+		await sleep();
+
+		const noteInB = (await bob.client.request('notes/timeline', {}))[0];
+		deepStrictEqual(noteInB.reactions[`:${emoji.name}@a.test:`], 1);
+		deepStrictEqual(noteInB.reactionEmojis[`${emoji.name}@a.test`], emoji.url);
+	});
+
+	test('Custom emoji are delivered with Profile delivery', async () => {
+		const emoji = await addCustomEmoji('a.test');
+		const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
+		await sleep();
+
+		const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
+		strictEqual(renewedaliceInB.name, renewedAlice.name);
+		assert(emoji.name in renewedaliceInB.emojis);
+		strictEqual(renewedaliceInB.emojis[emoji.name], emoji.url);
+	});
+
+	test('Local-only custom emoji aren\'t delivered with Note delivery', async () => {
+		const emoji = await addCustomEmoji('a.test', { localOnly: true });
+		await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
+		await sleep();
+
+		const notes = await bob.client.request('notes/timeline', {});
+		const noteInB = notes[0];
+
+		strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
+		// deepStrictEqual(noteInB.emojis, {}); // TODO: this fails (why?)
+		deepStrictEqual({ ...noteInB.emojis }, {});
+	});
+
+	test('Local-only custom emoji aren\'t delivered with Reaction delivery', async () => {
+		const emoji = await addCustomEmoji('a.test', { localOnly: true });
+		const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+		await sleep();
+
+		await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
+		await sleep();
+
+		const noteInB = (await bob.client.request('notes/timeline', {}))[0];
+		deepStrictEqual({ ...noteInB.reactions }, { '❤': 1 });
+		deepStrictEqual({ ...noteInB.reactionEmojis }, {});
+	});
+
+	test('Local-only custom emoji aren\'t delivered with Profile delivery', async () => {
+		const emoji = await addCustomEmoji('a.test', { localOnly: true });
+		const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
+		await sleep();
+
+		const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
+		strictEqual(renewedaliceInB.name, renewedAlice.name);
+		deepStrictEqual({ ...renewedaliceInB.emojis }, {});
+	});
+});
diff --git a/packages/backend/test-federation/test/move.test.ts b/packages/backend/test-federation/test/move.test.ts
new file mode 100644
index 0000000000..56a57de8a4
--- /dev/null
+++ b/packages/backend/test-federation/test/move.test.ts
@@ -0,0 +1,52 @@
+import assert, { strictEqual } from 'node:assert';
+import { createAccount, type LoginUser, sleep } from './utils.js';
+
+describe('Move', () => {
+	test('Minimum move', async () => {
+		const [alice, bob] = await Promise.all([
+			createAccount('a.test'),
+			createAccount('b.test'),
+		]);
+
+		await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] });
+		await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` });
+	});
+
+	/** @see https://github.com/misskey-dev/misskey/issues/11320 */
+	describe('Following relation is transferred after move', () => {
+		let alice: LoginUser, bob: LoginUser, carol: LoginUser;
+
+		beforeAll(async () => {
+			[alice, bob] = await Promise.all([
+				createAccount('a.test'),
+				createAccount('b.test'),
+			]);
+			carol = await createAccount('a.test');
+
+			// Follow @carol@a.test ==> @alice@a.test
+			await carol.client.request('following/create', { userId: alice.id });
+
+			// Move @alice@a.test ==> @bob@b.test
+			await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] });
+			await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` });
+			await sleep();
+		});
+
+		test('Check from follower', async () => {
+			const following = await carol.client.request('users/following', { userId: carol.id });
+			strictEqual(following.length, 2);
+			const followees = following.map(({ followee }) => followee);
+			assert(followees.every(followee => followee != null));
+			assert(followees.some(({ id, url }) => id === alice.id && url === null));
+			assert(followees.some(({ url }) => url === `https://b.test/@${bob.username}`));
+		});
+
+		test('Check from followee', async () => {
+			const followers = await bob.client.request('users/followers', { userId: bob.id });
+			strictEqual(followers.length, 1);
+			const follower = followers[0].follower;
+			assert(follower != null);
+			strictEqual(follower.url, `https://a.test/@${carol.username}`);
+		});
+	});
+});
diff --git a/packages/backend/test-federation/test/note.test.ts b/packages/backend/test-federation/test/note.test.ts
new file mode 100644
index 0000000000..bacc4cc54f
--- /dev/null
+++ b/packages/backend/test-federation/test/note.test.ts
@@ -0,0 +1,317 @@
+import assert, { rejects, strictEqual } from 'node:assert';
+import * as Misskey from 'misskey-js';
+import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
+
+describe('Note', () => {
+	let alice: LoginUser, bob: LoginUser;
+	let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+	beforeAll(async () => {
+		[alice, bob] = await Promise.all([
+			createAccount('a.test'),
+			createAccount('b.test'),
+		]);
+
+		[bobInA, aliceInB] = await Promise.all([
+			resolveRemoteUser('b.test', bob.id, alice),
+			resolveRemoteUser('a.test', alice.id, bob),
+		]);
+	});
+
+	describe('Note content', () => {
+		test('Consistency of Public Note', async () => {
+			const image = await uploadFile('a.test', alice);
+			const note = (await alice.client.request('notes/create', {
+				text: 'I am Alice!',
+				fileIds: [image.id],
+				poll: {
+					choices: ['neko', 'inu'],
+					multiple: false,
+					expiredAfter: 60 * 60 * 1000,
+				},
+			})).createdNote;
+
+			const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
+			deepStrictEqualWithExcludedFields(note, resolvedNote, [
+				'id',
+				'emojis',
+				/** Consistency of files is checked at {@link file://./drive.test.ts}, so let's skip. */
+				'fileIds',
+				'files',
+				/** @see https://github.com/misskey-dev/misskey/issues/12409 */
+				'reactionAcceptance',
+				'userId',
+				'user',
+				'uri',
+			]);
+			strictEqual(aliceInB.id, resolvedNote.userId);
+		});
+
+		test('Consistency of reply', async () => {
+			const _replyedNote = (await alice.client.request('notes/create', {
+				text: 'a',
+			})).createdNote;
+			const note = (await alice.client.request('notes/create', {
+				text: 'b',
+				replyId: _replyedNote.id,
+			})).createdNote;
+			// NOTE: the repliedCount is incremented, so fetch again
+			const replyedNote = await alice.client.request('notes/show', { noteId: _replyedNote.id });
+			strictEqual(replyedNote.repliesCount, 1);
+
+			const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
+			deepStrictEqualWithExcludedFields(note, resolvedNote, [
+				'id',
+				'emojis',
+				'reactionAcceptance',
+				'replyId',
+				'reply',
+				'userId',
+				'user',
+				'uri',
+			]);
+			assert(resolvedNote.replyId != null);
+			assert(resolvedNote.reply != null);
+			deepStrictEqualWithExcludedFields(replyedNote, resolvedNote.reply, [
+				'id',
+				// TODO: why clippedCount loses consistency?
+				'clippedCount',
+				'emojis',
+				'userId',
+				'user',
+				'uri',
+				// flaky because this is parallelly incremented, so let's check it below
+				'repliesCount',
+			]);
+			strictEqual(aliceInB.id, resolvedNote.userId);
+
+			await sleep();
+
+			const resolvedReplyedNote = await bob.client.request('notes/show', { noteId: resolvedNote.replyId });
+			strictEqual(resolvedReplyedNote.repliesCount, 1);
+		});
+
+		test('Consistency of Renote', async () => {
+			// NOTE: the renoteCount is not incremented, so no need to fetch again
+			const renotedNote = (await alice.client.request('notes/create', {
+				text: 'a',
+			})).createdNote;
+			const note = (await alice.client.request('notes/create', {
+				text: 'b',
+				renoteId: renotedNote.id,
+			})).createdNote;
+
+			const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
+			deepStrictEqualWithExcludedFields(note, resolvedNote, [
+				'id',
+				'emojis',
+				'reactionAcceptance',
+				'renoteId',
+				'renote',
+				'userId',
+				'user',
+				'uri',
+			]);
+			assert(resolvedNote.renoteId != null);
+			assert(resolvedNote.renote != null);
+			deepStrictEqualWithExcludedFields(renotedNote, resolvedNote.renote, [
+				'id',
+				'emojis',
+				'userId',
+				'user',
+				'uri',
+			]);
+			strictEqual(aliceInB.id, resolvedNote.userId);
+		});
+	});
+
+	describe('Other props', () => {
+		test('localOnly', async () => {
+			const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote;
+			rejects(
+				async () => await bob.client.request('ap/show', { uri: `https://a.test/notes/${note.id}` }),
+				(err: any) => {
+					/**
+					 * FIXME: this error is not handled
+					 * @see https://github.com/misskey-dev/misskey/issues/12736
+					 */
+					strictEqual(err.code, 'INTERNAL_ERROR');
+					return true;
+				},
+			);
+		});
+	});
+
+	describe('Deletion', () => {
+		describe('Check Delete consistency', () => {
+			let carol: LoginUser;
+
+			beforeAll(async () => {
+				carol = await createAccount('a.test');
+
+				await carol.client.request('following/create', { userId: bobInA.id });
+				await sleep();
+			});
+
+			test('Delete is derivered to followers', async () => {
+				const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
+				const noteInA = await resolveRemoteNote('b.test', note.id, carol);
+				await bob.client.request('notes/delete', { noteId: note.id });
+				await sleep();
+
+				await rejects(
+					async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
+					(err: any) => {
+						strictEqual(err.code, 'NO_SUCH_NOTE');
+						return true;
+					},
+				);
+			});
+		});
+
+		describe('Deletion of remote user\'s note for moderation', () => {
+			let note: Misskey.entities.Note;
+
+			test('Alice post is deleted in B', async () => {
+				note = (await alice.client.request('notes/create', { text: 'Hello' })).createdNote;
+				const noteInB = await resolveRemoteNote('a.test', note.id, bob);
+				const bMod = await createModerator('b.test');
+				await bMod.client.request('notes/delete', { noteId: noteInB.id });
+				await rejects(
+					async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
+					(err: any) => {
+						strictEqual(err.code, 'NO_SUCH_NOTE');
+						return true;
+					},
+				);
+			});
+
+			/**
+			 * FIXME: implement soft deletion as well as user?
+			 *        @see https://github.com/misskey-dev/misskey/issues/11437
+			 */
+			test.failing('Not found even if resolve again', async () => {
+				const noteInB = await resolveRemoteNote('a.test', note.id, bob);
+				await rejects(
+					async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
+					(err: any) => {
+						strictEqual(err.code, 'NO_SUCH_NOTE');
+						return true;
+					},
+				);
+			});
+		});
+	});
+
+	describe('Reaction', () => {
+		describe('Consistency', () => {
+			test('Unicode reaction', async () => {
+				const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+				const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
+				const reaction = '😅';
+				await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction });
+				await sleep();
+
+				const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
+				strictEqual(reactions.length, 1);
+				strictEqual(reactions[0].type, reaction);
+				strictEqual(reactions[0].user.id, bobInA.id);
+			});
+
+			test('Custom emoji reaction', async () => {
+				const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+				const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
+				const emoji = await addCustomEmoji('b.test');
+				await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: `:${emoji.name}:` });
+				await sleep();
+
+				const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
+				strictEqual(reactions.length, 1);
+				strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
+				strictEqual(reactions[0].user.id, bobInA.id);
+			});
+		});
+
+		describe('Acceptance', () => {
+			test('Even if likeOnly, remote users can react with custom emoji, but it is converted to like', async () => {
+				const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'likeOnly' })).createdNote;
+				const noteInB = await resolveRemoteNote('a.test', note.id, bob);
+				const emoji = await addCustomEmoji('b.test');
+				await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
+				await sleep();
+
+				const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
+				strictEqual(reactions.length, 1);
+				strictEqual(reactions[0].type, '❤');
+			});
+
+			/**
+			 * TODO: this may be unexpected behavior?
+			 *       @see https://github.com/misskey-dev/misskey/issues/12409
+			 */
+			test('Even if nonSensitiveOnly, remote users can react with sensitive emoji, and it is not converted', async () => {
+				const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'nonSensitiveOnly' })).createdNote;
+				const noteInB = await resolveRemoteNote('a.test', note.id, bob);
+				const emoji = await addCustomEmoji('b.test', { isSensitive: true });
+				await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
+				await sleep();
+
+				const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
+				strictEqual(reactions.length, 1);
+				strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
+			});
+		});
+	});
+
+	describe('Poll', () => {
+		describe('Any remote user\'s vote is delivered to the author', () => {
+			let carol: LoginUser;
+
+			beforeAll(async () => {
+				carol = await createAccount('a.test');
+			});
+
+			test('Bob creates poll and receives a vote from Carol', async () => {
+				const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote;
+				const noteInA = await resolveRemoteNote('b.test', note.id, carol);
+				await carol.client.request('notes/polls/vote', { noteId: noteInA.id, choice: 0 });
+				await sleep();
+
+				const noteAfterVote = await bob.client.request('notes/show', { noteId: note.id });
+				assert(noteAfterVote.poll != null);
+				strictEqual(noteAfterVote.poll.choices[0].votes, 1);
+				strictEqual(noteAfterVote.poll.choices[1].votes, 0);
+			});
+		});
+
+		describe('Local user\'s vote is delivered to the author\'s remote followers', () => {
+			let bobRemoteFollower: LoginUser, localVoter: LoginUser;
+
+			beforeAll(async () => {
+				[
+					bobRemoteFollower,
+					localVoter,
+				] = await Promise.all([
+					createAccount('a.test'),
+					createAccount('b.test'),
+				]);
+
+				await bobRemoteFollower.client.request('following/create', { userId: bobInA.id });
+				await sleep();
+			});
+
+			test('A vote in Bob\'s server is delivered to Bob\'s remote followers', async () => {
+				const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote;
+				// NOTE: resolve before voting
+				const noteInA = await resolveRemoteNote('b.test', note.id, bobRemoteFollower);
+				await localVoter.client.request('notes/polls/vote', { noteId: note.id, choice: 0 });
+				await sleep();
+
+				const noteAfterVote = await bobRemoteFollower.client.request('notes/show', { noteId: noteInA.id });
+				assert(noteAfterVote.poll != null);
+				strictEqual(noteAfterVote.poll.choices[0].votes, 1);
+				strictEqual(noteAfterVote.poll.choices[1].votes, 0);
+			});
+		});
+	});
+});
diff --git a/packages/backend/test-federation/test/notification.test.ts b/packages/backend/test-federation/test/notification.test.ts
new file mode 100644
index 0000000000..6d55353653
--- /dev/null
+++ b/packages/backend/test-federation/test/notification.test.ts
@@ -0,0 +1,107 @@
+import * as Misskey from 'misskey-js';
+import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
+
+describe('Notification', () => {
+	let alice: LoginUser, bob: LoginUser;
+	let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+	beforeAll(async () => {
+		[alice, bob] = await Promise.all([
+			createAccount('a.test'),
+			createAccount('b.test'),
+		]);
+
+		[bobInA, aliceInB] = await Promise.all([
+			resolveRemoteUser('b.test', bob.id, alice),
+			resolveRemoteUser('a.test', alice.id, bob),
+		]);
+	});
+
+	describe('Follow', () => {
+		test('Get notification when follow', async () => {
+			await assertNotificationReceived(
+				'b.test', bob,
+				async () => await bob.client.request('following/create', { userId: aliceInB.id }),
+				notification => notification.type === 'followRequestAccepted' && notification.userId === aliceInB.id,
+				true,
+			);
+
+			await bob.client.request('following/delete', { userId: aliceInB.id });
+			await sleep();
+		});
+
+		test('Get notification when get followed', async () => {
+			await assertNotificationReceived(
+				'a.test', alice,
+				async () => await bob.client.request('following/create', { userId: aliceInB.id }),
+				notification => notification.type === 'follow' && notification.userId === bobInA.id,
+				true,
+			);
+		});
+
+		afterAll(async () => await bob.client.request('following/delete', { userId: aliceInB.id }));
+	});
+
+	describe('Note', () => {
+		test('Get notification when get a reaction', async () => {
+			const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+			const noteInB = await resolveRemoteNote('a.test', note.id, bob);
+			const reaction = '😅';
+			await assertNotificationReceived(
+				'a.test', alice,
+				async () => await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction }),
+				notification =>
+					notification.type === 'reaction' && notification.note.id === note.id && notification.userId === bobInA.id && notification.reaction === reaction,
+				true,
+			);
+		});
+
+		test('Get notification when replied', async () => {
+			const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+			const noteInB = await resolveRemoteNote('a.test', note.id, bob);
+			const text = crypto.randomUUID();
+			await assertNotificationReceived(
+				'a.test', alice,
+				async () => await bob.client.request('notes/create', { text, replyId: noteInB.id }),
+				notification =>
+					notification.type === 'reply' && notification.note.reply!.id === note.id && notification.userId === bobInA.id && notification.note.text === text,
+				true,
+			);
+		});
+
+		test('Get notification when renoted', async () => {
+			const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+			const noteInB = await resolveRemoteNote('a.test', note.id, bob);
+			await assertNotificationReceived(
+				'a.test', alice,
+				async () => await bob.client.request('notes/create', { renoteId: noteInB.id }),
+				notification =>
+					notification.type === 'renote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id,
+				true,
+			);
+		});
+
+		test('Get notification when quoted', async () => {
+			const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+			const noteInB = await resolveRemoteNote('a.test', note.id, bob);
+			const text = crypto.randomUUID();
+			await assertNotificationReceived(
+				'a.test', alice,
+				async () => await bob.client.request('notes/create', { text, renoteId: noteInB.id }),
+				notification =>
+					notification.type === 'quote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id && notification.note.text === text,
+				true,
+			);
+		});
+
+		test('Get notification when mentioned', async () => {
+			const text = `@${alice.username}@a.test`;
+			await assertNotificationReceived(
+				'a.test', alice,
+				async () => await bob.client.request('notes/create', { text }),
+				notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text,
+				true,
+			);
+		});
+	});
+});
diff --git a/packages/backend/test-federation/test/timeline.test.ts b/packages/backend/test-federation/test/timeline.test.ts
new file mode 100644
index 0000000000..2250bf4a42
--- /dev/null
+++ b/packages/backend/test-federation/test/timeline.test.ts
@@ -0,0 +1,328 @@
+import { strictEqual } from 'assert';
+import * as Misskey from 'misskey-js';
+import { createAccount, fetchAdmin, isNoteUpdatedEventFired, isFired, type LoginUser, type Request, resolveRemoteUser, sleep, createRole } from './utils.js';
+
+const bAdmin = await fetchAdmin('b.test');
+
+describe('Timeline', () => {
+	let alice: LoginUser, bob: LoginUser;
+	let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+	beforeAll(async () => {
+		[alice, bob] = await Promise.all([
+			createAccount('a.test'),
+			createAccount('b.test'),
+		]);
+
+		[bobInA, aliceInB] = await Promise.all([
+			resolveRemoteUser('b.test', bob.id, alice),
+			resolveRemoteUser('a.test', alice.id, bob),
+		]);
+
+		await bob.client.request('following/create', { userId: aliceInB.id });
+		await sleep();
+	});
+
+	type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag');
+	type TimelineEndpoint = keyof Misskey.Endpoints & (`${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag');
+	const timelineMap = new Map<TimelineChannel, TimelineEndpoint>([
+		['antenna', 'antennas/notes'],
+		['globalTimeline', 'notes/global-timeline'],
+		['homeTimeline', 'notes/timeline'],
+		['hybridTimeline', 'notes/hybrid-timeline'],
+		['localTimeline', 'notes/local-timeline'],
+		['roleTimeline', 'roles/notes'],
+		['hashtag', 'notes/search-by-tag'],
+		['userList', 'notes/user-list-timeline'],
+	]);
+
+	async function postAndCheckReception<C extends TimelineChannel>(
+		timelineChannel: C,
+		expect: boolean,
+		noteParams: Misskey.entities.NotesCreateRequest = {},
+		channelParams: Misskey.Channels[C]['params'] = {},
+	) {
+		let note: Misskey.entities.Note | undefined;
+		const text = noteParams.text ?? crypto.randomUUID();
+		const streamingFired = await isFired(
+			'b.test', bob, timelineChannel,
+			async () => {
+				note = (await alice.client.request('notes/create', { text, ...noteParams })).createdNote;
+			},
+			'note', msg => msg.text === text,
+			channelParams,
+		);
+		strictEqual(streamingFired, expect);
+
+		const endpoint = timelineMap.get(timelineChannel)!;
+		const params: Misskey.Endpoints[typeof endpoint]['req'] =
+			endpoint === 'antennas/notes' ? { antennaId: (channelParams as Misskey.Channels['antenna']['params']).antennaId } :
+			endpoint === 'notes/user-list-timeline' ? { listId: (channelParams as Misskey.Channels['userList']['params']).listId } :
+			endpoint === 'notes/search-by-tag' ? { query: (channelParams as Misskey.Channels['hashtag']['params']).q } :
+			endpoint === 'roles/notes' ? { roleId: (channelParams as Misskey.Channels['roleTimeline']['params']).roleId } :
+			{};
+
+		await sleep();
+		const notes = await (bob.client.request as Request)(endpoint, params);
+		const noteInB = notes.filter(({ uri }) => uri === `https://a.test/notes/${note!.id}`).pop();
+		const endpointFired = noteInB != null;
+		strictEqual(endpointFired, expect);
+
+		// Let's check Delete reception
+		if (expect) {
+			const streamingFired = await isNoteUpdatedEventFired(
+				'b.test', bob, noteInB!.id,
+				async () => await alice.client.request('notes/delete', { noteId: note!.id }),
+				msg => msg.type === 'deleted' && msg.id === noteInB!.id,
+			);
+			strictEqual(streamingFired, true);
+
+			await sleep();
+			const notes = await (bob.client.request as Request)(endpoint, params);
+			const endpointFired = notes.every(({ uri }) => uri !== `https://a.test/notes/${note!.id}`);
+			strictEqual(endpointFired, true);
+		}
+	}
+
+	describe('homeTimeline', () => {
+		// NOTE: narrowing scope intentionally to prevent mistakes by copy-and-paste
+		const homeTimeline = 'homeTimeline';
+
+		describe('Check reception of remote followee\'s Note', () => {
+			test('Receive remote followee\'s Note', async () => {
+				await postAndCheckReception(homeTimeline, true);
+			});
+
+			test('Receive remote followee\'s home-only Note', async () => {
+				await postAndCheckReception(homeTimeline, true, { visibility: 'home' });
+			});
+
+			test('Receive remote followee\'s followers-only Note', async () => {
+				await postAndCheckReception(homeTimeline, true, { visibility: 'followers' });
+			});
+
+			test('Receive remote followee\'s visible specified-only Note', async () => {
+				await postAndCheckReception(homeTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] });
+			});
+
+			test('Don\'t receive remote followee\'s localOnly Note', async () => {
+				await postAndCheckReception(homeTimeline, false, { localOnly: true });
+			});
+
+			test('Don\'t receive remote followee\'s invisible specified-only Note', async () => {
+				await postAndCheckReception(homeTimeline, false, { visibility: 'specified' });
+			});
+
+			/**
+			 * FIXME: can receive this
+			 * @see https://github.com/misskey-dev/misskey/issues/14083
+			 */
+			test.failing('Don\'t receive remote followee\'s invisible and mentioned specified-only Note', async () => {
+				await postAndCheckReception(homeTimeline, false, { text: `@${bob.username}@b.test Hello`, visibility: 'specified' });
+			});
+
+			/**
+			 * FIXME: cannot receive this
+			 * @see https://github.com/misskey-dev/misskey/issues/14084
+			 */
+			test.failing('Receive remote followee\'s visible specified-only reply to invisible specified-only Note', async () => {
+				const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'specified' })).createdNote;
+				await postAndCheckReception(homeTimeline, true, { replyId: note.id, visibility: 'specified', visibleUserIds: [bobInA.id] });
+			});
+		});
+	});
+
+	describe('localTimeline', () => {
+		const localTimeline = 'localTimeline';
+
+		describe('Check reception of remote followee\'s Note', () => {
+			test('Don\'t receive remote followee\'s Note', async () => {
+				await postAndCheckReception(localTimeline, false);
+			});
+		});
+	});
+
+	describe('hybridTimeline', () => {
+		const hybridTimeline = 'hybridTimeline';
+
+		describe('Check reception of remote followee\'s Note', () => {
+			test('Receive remote followee\'s Note', async () => {
+				await postAndCheckReception(hybridTimeline, true);
+			});
+
+			test('Receive remote followee\'s home-only Note', async () => {
+				await postAndCheckReception(hybridTimeline, true, { visibility: 'home' });
+			});
+
+			test('Receive remote followee\'s followers-only Note', async () => {
+				await postAndCheckReception(hybridTimeline, true, { visibility: 'followers' });
+			});
+
+			test('Receive remote followee\'s visible specified-only Note', async () => {
+				await postAndCheckReception(hybridTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] });
+			});
+		});
+	});
+
+	describe('globalTimeline', () => {
+		const globalTimeline = 'globalTimeline';
+
+		describe('Check reception of remote followee\'s Note', () => {
+			test('Receive remote followee\'s Note', async () => {
+				await postAndCheckReception(globalTimeline, true);
+			});
+
+			test('Don\'t receive remote followee\'s home-only Note', async () => {
+				await postAndCheckReception(globalTimeline, false, { visibility: 'home' });
+			});
+
+			test('Don\'t receive remote followee\'s followers-only Note', async () => {
+				await postAndCheckReception(globalTimeline, false, { visibility: 'followers' });
+			});
+
+			test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
+				await postAndCheckReception(globalTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] });
+			});
+		});
+	});
+
+	describe('userList', () => {
+		const userList = 'userList';
+
+		let list: Misskey.entities.UserList;
+
+		beforeAll(async () => {
+			list = await bob.client.request('users/lists/create', { name: 'Bob\'s List' });
+			await bob.client.request('users/lists/push', { listId: list.id, userId: aliceInB.id });
+			await sleep();
+		});
+
+		describe('Check reception of remote followee\'s Note', () => {
+			test('Receive remote followee\'s Note', async () => {
+				await postAndCheckReception(userList, true, {}, { listId: list.id });
+			});
+
+			test('Receive remote followee\'s home-only Note', async () => {
+				await postAndCheckReception(userList, true, { visibility: 'home' }, { listId: list.id });
+			});
+
+			test('Receive remote followee\'s followers-only Note', async () => {
+				await postAndCheckReception(userList, true, { visibility: 'followers' }, { listId: list.id });
+			});
+
+			test('Receive remote followee\'s visible specified-only Note', async () => {
+				await postAndCheckReception(userList, true, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { listId: list.id });
+			});
+		});
+	});
+
+	describe('hashtag', () => {
+		const hashtag = 'hashtag';
+
+		describe('Check reception of remote followee\'s Note', () => {
+			test('Receive remote followee\'s Note', async () => {
+				const tag = crypto.randomUUID();
+				await postAndCheckReception(hashtag, true, { text: `#${tag}` }, { q: [[tag]] });
+			});
+
+			test('Receive remote followee\'s home-only Note', async () => {
+				const tag = crypto.randomUUID();
+				await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'home' }, { q: [[tag]] });
+			});
+
+			test('Receive remote followee\'s followers-only Note', async () => {
+				const tag = crypto.randomUUID();
+				await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'followers' }, { q: [[tag]] });
+			});
+
+			test('Receive remote followee\'s visible specified-only Note', async () => {
+				const tag = crypto.randomUUID();
+				await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'specified', visibleUserIds: [bobInA.id] }, { q: [[tag]] });
+			});
+		});
+	});
+
+	describe('roleTimeline', () => {
+		const roleTimeline = 'roleTimeline';
+
+		let role: Misskey.entities.Role;
+
+		beforeAll(async () => {
+			role = await createRole('b.test', {
+				name: 'Remote Users',
+				description: 'Remote users are assigned to this role.',
+				condFormula: {
+					/** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */
+					type: 'isRemote' as never,
+				},
+			});
+			await sleep();
+		});
+
+		describe('Check reception of remote followee\'s Note', () => {
+			test('Receive remote followee\'s Note', async () => {
+				await postAndCheckReception(roleTimeline, true, {}, { roleId: role.id });
+			});
+
+			test('Don\'t receive remote followee\'s home-only Note', async () => {
+				await postAndCheckReception(roleTimeline, false, { visibility: 'home' }, { roleId: role.id });
+			});
+
+			test('Don\'t receive remote followee\'s followers-only Note', async () => {
+				await postAndCheckReception(roleTimeline, false, { visibility: 'followers' }, { roleId: role.id });
+			});
+
+			test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
+				await postAndCheckReception(roleTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { roleId: role.id });
+			});
+		});
+
+		afterAll(async () => {
+			await bAdmin.client.request('admin/roles/delete', { roleId: role.id });
+		});
+	});
+
+	// TODO: Cannot test
+	describe.skip('antenna', () => {
+		const antenna = 'antenna';
+
+		let bobAntenna: Misskey.entities.Antenna;
+
+		beforeAll(async () => {
+			bobAntenna = await bob.client.request('antennas/create', {
+				name: 'Bob\'s Egosurfing Antenna',
+				src: 'all',
+				keywords: [['Bob']],
+				excludeKeywords: [],
+				users: [],
+				caseSensitive: false,
+				localOnly: false,
+				withReplies: true,
+				withFile: true,
+			});
+			await sleep();
+		});
+
+		describe('Check reception of remote followee\'s Note', () => {
+			test('Receive remote followee\'s Note', async () => {
+				await postAndCheckReception(antenna, true, { text: 'I love Bob (1)' }, { antennaId: bobAntenna.id });
+			});
+
+			test('Don\'t receive remote followee\'s home-only Note', async () => {
+				await postAndCheckReception(antenna, false, { text: 'I love Bob (2)', visibility: 'home' }, { antennaId: bobAntenna.id });
+			});
+
+			test('Don\'t receive remote followee\'s followers-only Note', async () => {
+				await postAndCheckReception(antenna, false, { text: 'I love Bob (3)', visibility: 'followers' }, { antennaId: bobAntenna.id });
+			});
+
+			test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
+				await postAndCheckReception(antenna, false, { text: 'I love Bob (4)', visibility: 'specified', visibleUserIds: [bobInA.id] }, { antennaId: bobAntenna.id });
+			});
+		});
+
+		afterAll(async () => {
+			await bob.client.request('antennas/delete', { antennaId: bobAntenna.id });
+		});
+	});
+});
diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts
new file mode 100644
index 0000000000..76605e61d4
--- /dev/null
+++ b/packages/backend/test-federation/test/user.test.ts
@@ -0,0 +1,560 @@
+import assert, { rejects, strictEqual } from 'node:assert';
+import * as Misskey from 'misskey-js';
+import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
+
+const [aAdmin, bAdmin] = await Promise.all([
+	fetchAdmin('a.test'),
+	fetchAdmin('b.test'),
+]);
+
+describe('User', () => {
+	describe('Profile', () => {
+		describe('Consistency of profile', () => {
+			let alice: LoginUser;
+			let aliceWatcher: LoginUser;
+			let aliceWatcherInB: LoginUser;
+
+			beforeAll(async () => {
+				alice = await createAccount('a.test');
+				[
+					aliceWatcher,
+					aliceWatcherInB,
+				] = await Promise.all([
+					createAccount('a.test'),
+					createAccount('b.test'),
+				]);
+			});
+
+			test('Check consistency', async () => {
+				const aliceInA = await aliceWatcher.client.request('users/show', { userId: alice.id });
+				const resolved = await resolveRemoteUser('a.test', aliceInA.id, aliceWatcherInB);
+				const aliceInB = await aliceWatcherInB.client.request('users/show', { userId: resolved.id });
+
+				// console.log(`a.test: ${JSON.stringify(aliceInA, null, '\t')}`);
+				// console.log(`b.test: ${JSON.stringify(aliceInB, null, '\t')}`);
+
+				deepStrictEqualWithExcludedFields(aliceInA, aliceInB, [
+					'id',
+					'host',
+					'avatarUrl',
+					'instance',
+					'badgeRoles',
+					'url',
+					'uri',
+					'createdAt',
+					'lastFetchedAt',
+					'publicReactions',
+				]);
+			});
+		});
+
+		describe('ffVisibility is federated', () => {
+			let alice: LoginUser, bob: LoginUser;
+			let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+			beforeAll(async () => {
+				[alice, bob] = await Promise.all([
+					createAccount('a.test'),
+					createAccount('b.test'),
+				]);
+
+				[bobInA, aliceInB] = await Promise.all([
+					resolveRemoteUser('b.test', bob.id, alice),
+					resolveRemoteUser('a.test', alice.id, bob),
+				]);
+
+				// NOTE: follow each other
+				await Promise.all([
+					alice.client.request('following/create', { userId: bobInA.id }),
+					bob.client.request('following/create', { userId: aliceInB.id }),
+				]);
+				await sleep();
+			});
+
+			test('Visibility set public by default', async () => {
+				for (const user of await Promise.all([
+					alice.client.request('users/show', { userId: bobInA.id }),
+					bob.client.request('users/show', { userId: aliceInB.id }),
+				])) {
+					strictEqual(user.followersVisibility, 'public');
+					strictEqual(user.followingVisibility, 'public');
+				}
+			});
+
+			/** FIXME: not working */
+			test.skip('Setting private for followersVisibility is federated', async () => {
+				await Promise.all([
+					alice.client.request('i/update', { followersVisibility: 'private' }),
+					bob.client.request('i/update', { followersVisibility: 'private' }),
+				]);
+				await sleep();
+
+				for (const user of await Promise.all([
+					alice.client.request('users/show', { userId: bobInA.id }),
+					bob.client.request('users/show', { userId: aliceInB.id }),
+				])) {
+					strictEqual(user.followersVisibility, 'private');
+					strictEqual(user.followingVisibility, 'public');
+				}
+			});
+
+			test.skip('Setting private for followingVisibility is federated', async () => {
+				await Promise.all([
+					alice.client.request('i/update', { followingVisibility: 'private' }),
+					bob.client.request('i/update', { followingVisibility: 'private' }),
+				]);
+				await sleep();
+
+				for (const user of await Promise.all([
+					alice.client.request('users/show', { userId: bobInA.id }),
+					bob.client.request('users/show', { userId: aliceInB.id }),
+				])) {
+					strictEqual(user.followersVisibility, 'private');
+					strictEqual(user.followingVisibility, 'private');
+				}
+			});
+		});
+
+		describe('isCat is federated', () => {
+			let alice: LoginUser, bob: LoginUser;
+			let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+			beforeAll(async () => {
+				[alice, bob] = await Promise.all([
+					createAccount('a.test'),
+					createAccount('b.test'),
+				]);
+
+				[bobInA, aliceInB] = await Promise.all([
+					resolveRemoteUser('b.test', bob.id, alice),
+					resolveRemoteUser('a.test', alice.id, bob),
+				]);
+			});
+
+			test('Not isCat for default', () => {
+				strictEqual(aliceInB.isCat, false);
+			});
+
+			test('Becoming a cat is sent to their followers', async () => {
+				await bob.client.request('following/create', { userId: aliceInB.id });
+				await sleep();
+
+				await alice.client.request('i/update', { isCat: true });
+				await sleep();
+
+				const res = await bob.client.request('users/show', { userId: aliceInB.id });
+				strictEqual(res.isCat, true);
+			});
+		});
+
+		describe('Pinning Notes', () => {
+			let alice: LoginUser, bob: LoginUser;
+			let aliceInB: Misskey.entities.UserDetailedNotMe;
+
+			beforeAll(async () => {
+				[alice, bob] = await Promise.all([
+					createAccount('a.test'),
+					createAccount('b.test'),
+				]);
+				aliceInB = await resolveRemoteUser('a.test', alice.id, bob);
+
+				await bob.client.request('following/create', { userId: aliceInB.id });
+			});
+
+			test('Pinning localOnly Note is not delivered', async () => {
+				const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote;
+				await alice.client.request('i/pin', { noteId: note.id });
+				await sleep();
+
+				const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
+				strictEqual(_aliceInB.pinnedNoteIds.length, 0);
+			});
+
+			test('Pinning followers-only Note is not delivered', async () => {
+				const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'followers' })).createdNote;
+				await alice.client.request('i/pin', { noteId: note.id });
+				await sleep();
+
+				const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
+				strictEqual(_aliceInB.pinnedNoteIds.length, 0);
+			});
+
+			let pinnedNote: Misskey.entities.Note;
+
+			test('Pinning normal Note is delivered', async () => {
+				pinnedNote = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
+				await alice.client.request('i/pin', { noteId: pinnedNote.id });
+				await sleep();
+
+				const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
+				strictEqual(_aliceInB.pinnedNoteIds.length, 1);
+				const pinnedNoteInB = await resolveRemoteNote('a.test', pinnedNote.id, bob);
+				strictEqual(_aliceInB.pinnedNotes[0].id, pinnedNoteInB.id);
+			});
+
+			test('Unpinning normal Note is delivered', async () => {
+				await alice.client.request('i/unpin', { noteId: pinnedNote.id });
+				await sleep();
+
+				const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
+				strictEqual(_aliceInB.pinnedNoteIds.length, 0);
+			});
+		});
+	});
+
+	describe('Follow / Unfollow', () => {
+		let alice: LoginUser, bob: LoginUser;
+		let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+		beforeAll(async () => {
+			[alice, bob] = await Promise.all([
+				createAccount('a.test'),
+				createAccount('b.test'),
+			]);
+
+			[bobInA, aliceInB] = await Promise.all([
+				resolveRemoteUser('b.test', bob.id, alice),
+				resolveRemoteUser('a.test', alice.id, bob),
+			]);
+		});
+
+		describe('Follow a.test ==> b.test', () => {
+			beforeAll(async () => {
+				await alice.client.request('following/create', { userId: bobInA.id });
+
+				await sleep();
+			});
+
+			test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
+				await Promise.all([
+					strictEqual(
+						(await alice.client.request('users/following', { userId: alice.id }))
+							.some(v => v.followeeId === bobInA.id),
+						true,
+					),
+					strictEqual(
+						(await bob.client.request('users/followers', { userId: bob.id }))
+							.some(v => v.followerId === aliceInB.id),
+						true,
+					),
+				]);
+			});
+		});
+
+		describe('Unfollow a.test ==> b.test', () => {
+			beforeAll(async () => {
+				await alice.client.request('following/delete', { userId: bobInA.id });
+
+				await sleep();
+			});
+
+			test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
+				await Promise.all([
+					strictEqual(
+						(await alice.client.request('users/following', { userId: alice.id }))
+							.some(v => v.followeeId === bobInA.id),
+						false,
+					),
+					strictEqual(
+						(await bob.client.request('users/followers', { userId: bob.id }))
+							.some(v => v.followerId === aliceInB.id),
+						false,
+					),
+				]);
+			});
+		});
+	});
+
+	describe('Follow requests', () => {
+		let alice: LoginUser, bob: LoginUser;
+		let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+		beforeAll(async () => {
+			[alice, bob] = await Promise.all([
+				createAccount('a.test'),
+				createAccount('b.test'),
+			]);
+
+			[bobInA, aliceInB] = await Promise.all([
+				resolveRemoteUser('b.test', bob.id, alice),
+				resolveRemoteUser('a.test', alice.id, bob),
+			]);
+
+			await alice.client.request('i/update', { isLocked: true });
+		});
+
+		describe('Send follow request from Bob to Alice and cancel', () => {
+			describe('Bob sends follow request to Alice', () => {
+				beforeAll(async () => {
+					await bob.client.request('following/create', { userId: aliceInB.id });
+					await sleep();
+				});
+
+				test('Alice should have a request', async () => {
+					const requests = await alice.client.request('following/requests/list', {});
+					strictEqual(requests.length, 1);
+					strictEqual(requests[0].followee.id, alice.id);
+					strictEqual(requests[0].follower.id, bobInA.id);
+				});
+			});
+
+			describe('Alice cancels it', () => {
+				beforeAll(async () => {
+					await bob.client.request('following/requests/cancel', { userId: aliceInB.id });
+					await sleep();
+				});
+
+				test('Alice should have no requests', async () => {
+					const requests = await alice.client.request('following/requests/list', {});
+					strictEqual(requests.length, 0);
+				});
+			});
+		});
+
+		describe('Send follow request from Bob to Alice and reject', () => {
+			beforeAll(async () => {
+				await bob.client.request('following/create', { userId: aliceInB.id });
+				await sleep();
+
+				await alice.client.request('following/requests/reject', { userId: bobInA.id });
+				await sleep();
+			});
+
+			test('Bob should have no requests', async () => {
+				await rejects(
+					async () => await bob.client.request('following/requests/cancel', { userId: aliceInB.id }),
+					(err: any) => {
+						strictEqual(err.code, 'FOLLOW_REQUEST_NOT_FOUND');
+						return true;
+					},
+				);
+			});
+
+			test('Bob doesn\'t follow Alice', async () => {
+				const following = await bob.client.request('users/following', { userId: bob.id });
+				strictEqual(following.length, 0);
+			});
+		});
+
+		describe('Send follow request from Bob to Alice and accept', () => {
+			beforeAll(async () => {
+				await bob.client.request('following/create', { userId: aliceInB.id });
+				await sleep();
+
+				await alice.client.request('following/requests/accept', { userId: bobInA.id });
+				await sleep();
+			});
+
+			test('Bob follows Alice', async () => {
+				const following = await bob.client.request('users/following', { userId: bob.id });
+				strictEqual(following.length, 1);
+				strictEqual(following[0].followeeId, aliceInB.id);
+				strictEqual(following[0].followerId, bob.id);
+			});
+		});
+	});
+
+	describe('Deletion', () => {
+		describe('Check Delete consistency', () => {
+			let alice: LoginUser, bob: LoginUser;
+			let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+			beforeAll(async () => {
+				[alice, bob] = await Promise.all([
+					createAccount('a.test'),
+					createAccount('b.test'),
+				]);
+
+				[bobInA, aliceInB] = await Promise.all([
+					resolveRemoteUser('b.test', bob.id, alice),
+					resolveRemoteUser('a.test', alice.id, bob),
+				]);
+			});
+
+			test('Bob follows Alice, and Alice deleted themself', async () => {
+				await bob.client.request('following/create', { userId: aliceInB.id });
+				await sleep();
+
+				const followers = await alice.client.request('users/followers', { userId: alice.id });
+				strictEqual(followers.length, 1); // followed by Bob
+
+				await alice.client.request('i/delete-account', { password: alice.password });
+				await sleep();
+
+				const following = await bob.client.request('users/following', { userId: bob.id });
+				strictEqual(following.length, 0); // no following relation
+
+				await rejects(
+					async () => await bob.client.request('following/create', { userId: aliceInB.id }),
+					(err: any) => {
+						strictEqual(err.code, 'NO_SUCH_USER');
+						return true;
+					},
+				);
+			});
+		});
+
+		describe('Deletion of remote user for moderation', () => {
+			let alice: LoginUser, bob: LoginUser;
+			let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+			beforeAll(async () => {
+				[alice, bob] = await Promise.all([
+					createAccount('a.test'),
+					createAccount('b.test'),
+				]);
+
+				[bobInA, aliceInB] = await Promise.all([
+					resolveRemoteUser('b.test', bob.id, alice),
+					resolveRemoteUser('a.test', alice.id, bob),
+				]);
+			});
+
+			test('Bob follows Alice, then Alice gets deleted in B server', async () => {
+				await bob.client.request('following/create', { userId: aliceInB.id });
+				await sleep();
+
+				const followers = await alice.client.request('users/followers', { userId: alice.id });
+				strictEqual(followers.length, 1); // followed by Bob
+
+				await bAdmin.client.request('admin/delete-account', { userId: aliceInB.id });
+				await sleep();
+
+				/**
+				 * FIXME: remote account is not deleted!
+				 *        @see https://github.com/misskey-dev/misskey/issues/14728
+				 */
+				const deletedAlice = await bob.client.request('users/show', { userId: aliceInB.id });
+				assert(deletedAlice.id, aliceInB.id);
+
+				// TODO: why still following relation?
+				const following = await bob.client.request('users/following', { userId: bob.id });
+				strictEqual(following.length, 1);
+				await rejects(
+					async () => await bob.client.request('following/create', { userId: aliceInB.id }),
+					(err: any) => {
+						strictEqual(err.code, 'ALREADY_FOLLOWING');
+						return true;
+					},
+				);
+			});
+
+			test('Alice tries to follow Bob, but it is not processed', async () => {
+				await alice.client.request('following/create', { userId: bobInA.id });
+				await sleep();
+
+				const following = await alice.client.request('users/following', { userId: alice.id });
+				strictEqual(following.length, 0); // Not following Bob because B server doesn't return Accept
+
+				const followers = await bob.client.request('users/followers', { userId: bob.id });
+				strictEqual(followers.length, 0); // Alice's Follow is not processed
+			});
+		});
+	});
+
+	describe('Suspension', () => {
+		describe('Check suspend/unsuspend consistency', () => {
+			let alice: LoginUser, bob: LoginUser;
+			let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
+
+			beforeAll(async () => {
+				[alice, bob] = await Promise.all([
+					createAccount('a.test'),
+					createAccount('b.test'),
+				]);
+
+				[bobInA, aliceInB] = await Promise.all([
+					resolveRemoteUser('b.test', bob.id, alice),
+					resolveRemoteUser('a.test', alice.id, bob),
+				]);
+			});
+
+			test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => {
+				await bob.client.request('following/create', { userId: aliceInB.id });
+				await sleep();
+
+				const followers = await alice.client.request('users/followers', { userId: alice.id });
+				strictEqual(followers.length, 1); // followed by Bob
+
+				await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
+				await sleep();
+
+				const following = await bob.client.request('users/following', { userId: bob.id });
+				strictEqual(following.length, 0); // no following relation
+
+				await rejects(
+					async () => await bob.client.request('following/create', { userId: aliceInB.id }),
+					(err: any) => {
+						strictEqual(err.code, 'NO_SUCH_USER');
+						return true;
+					},
+				);
+			});
+
+			test('Alice gets unsuspended, Bob succeeds in following Alice', async () => {
+				await aAdmin.client.request('admin/unsuspend-user', { userId: alice.id });
+				await sleep();
+
+				const followers = await alice.client.request('users/followers', { userId: alice.id });
+				strictEqual(followers.length, 1); // FIXME: followers are not deleted??
+
+				/**
+				 * FIXME: still rejected!
+				 *        seems to can't process Undo Delete activity because it is not implemented
+				 *        related @see https://github.com/misskey-dev/misskey/issues/13273
+				 */
+				await rejects(
+					async () => await bob.client.request('following/create', { userId: aliceInB.id }),
+					(err: any) => {
+						strictEqual(err.code, 'NO_SUCH_USER');
+						return true;
+					},
+				);
+
+				// FIXME: resolving also fails
+				await rejects(
+					async () => await resolveRemoteUser('a.test', alice.id, bob),
+					(err: any) => {
+						strictEqual(err.code, 'INTERNAL_ERROR');
+						return true;
+					},
+				);
+			});
+
+			/**
+			 * instead of simple unsuspension, let's tell existence by following from Alice
+			 */
+			test('Alice can follow Bob', async () => {
+				await alice.client.request('following/create', { userId: bobInA.id });
+				await sleep();
+
+				const bobFollowers = await bob.client.request('users/followers', { userId: bob.id });
+				strictEqual(bobFollowers.length, 1); // followed by Alice
+				assert(bobFollowers[0].follower != null);
+				const renewedaliceInB = bobFollowers[0].follower;
+				assert(aliceInB.username === renewedaliceInB.username);
+				assert(aliceInB.host === renewedaliceInB.host);
+				assert(aliceInB.id !== renewedaliceInB.id); // TODO: Same username and host, but their ids are different! Is it OK?
+
+				const following = await bob.client.request('users/following', { userId: bob.id });
+				strictEqual(following.length, 0); // following are deleted
+
+				// Bob tries to follow Alice
+				await bob.client.request('following/create', { userId: renewedaliceInB.id });
+				await sleep();
+
+				const aliceFollowers = await alice.client.request('users/followers', { userId: alice.id });
+				strictEqual(aliceFollowers.length, 1);
+
+				// FIXME: but resolving still fails ...
+				await rejects(
+					async () => await resolveRemoteUser('a.test', alice.id, bob),
+					(err: any) => {
+						strictEqual(err.code, 'INTERNAL_ERROR');
+						return true;
+					},
+				);
+			});
+		});
+	});
+});
diff --git a/packages/backend/test-federation/test/utils.ts b/packages/backend/test-federation/test/utils.ts
new file mode 100644
index 0000000000..483bf4b254
--- /dev/null
+++ b/packages/backend/test-federation/test/utils.ts
@@ -0,0 +1,309 @@
+import { deepStrictEqual, strictEqual } from 'assert';
+import { readFile } from 'fs/promises';
+import { dirname, join } from 'path';
+import { fileURLToPath } from 'url';
+import * as Misskey from 'misskey-js';
+import { WebSocket } from 'ws';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+export const ADMIN_PARAMS = { username: 'admin', password: 'admin' };
+const ADMIN_CACHE = new Map<Host, SigninResponse>();
+
+await Promise.all([
+	fetchAdmin('a.test'),
+	fetchAdmin('b.test'),
+]);
+
+type SigninResponse = Omit<Misskey.entities.SigninFlowResponse & { finished: true }, 'finished'>;
+
+export type LoginUser = SigninResponse & {
+	client: Misskey.api.APIClient;
+	username: string;
+	password: string;
+}
+
+/** used for avoiding overload and some endpoints */
+export type Request = <
+	E extends keyof Misskey.Endpoints,
+	P extends Misskey.Endpoints[E]['req'],
+>(
+	endpoint: E,
+	params: P,
+	credential?: string | null,
+) => Promise<Misskey.api.SwitchCaseResponseType<E, P>>;
+
+type Host = 'a.test' | 'b.test';
+
+export async function sleep(ms = 200): Promise<void> {
+	return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+async function signin(
+	host: Host,
+	params: Misskey.entities.SigninFlowRequest,
+): Promise<SigninResponse> {
+	// wait for a second to prevent hit rate limit
+	await sleep(1000);
+
+	return await (new Misskey.api.APIClient({ origin: `https://${host}` }).request as Request)('signin-flow', params)
+		.then(res => {
+			strictEqual(res.finished, true);
+			if (params.username === ADMIN_PARAMS.username) ADMIN_CACHE.set(host, res);
+			return res;
+		})
+		.then(({ id, i }) => ({ id, i }))
+		.catch(async err => {
+			if (err.code === 'TOO_MANY_AUTHENTICATION_FAILURES') {
+				await sleep(Math.random() * 2000);
+				return await signin(host, params);
+			}
+			throw err;
+		});
+}
+
+async function createAdmin(host: Host): Promise<Misskey.entities.SignupResponse | undefined> {
+	const client = new Misskey.api.APIClient({ origin: `https://${host}` });
+	return await client.request('admin/accounts/create', ADMIN_PARAMS).then(res => {
+		ADMIN_CACHE.set(host, {
+			id: res.id,
+			// @ts-expect-error FIXME: openapi-typescript generates incorrect response type for this endpoint, so ignore this
+			i: res.token,
+		});
+		return res as Misskey.entities.SignupResponse;
+	}).then(async res => {
+		await client.request('admin/roles/update-default-policies', {
+			policies: {
+				/** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */
+				rateLimitFactor: 0 as never,
+			},
+		}, res.token);
+		return res;
+	}).catch(err => {
+		if (err.info.e.message === 'access denied') return undefined;
+		throw err;
+	});
+}
+
+export async function fetchAdmin(host: Host): Promise<LoginUser> {
+	const admin = ADMIN_CACHE.get(host) ?? await signin(host, ADMIN_PARAMS)
+		.catch(async err => {
+			if (err.id === '6cc579cc-885d-43d8-95c2-b8c7fc963280') {
+				await createAdmin(host);
+				return await signin(host, ADMIN_PARAMS);
+			}
+			throw err;
+		});
+
+	return {
+		...admin,
+		client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: admin.i }),
+		...ADMIN_PARAMS,
+	};
+}
+
+export async function createAccount(host: Host): Promise<LoginUser> {
+	const username = crypto.randomUUID().replaceAll('-', '').substring(0, 20);
+	const password = crypto.randomUUID().replaceAll('-', '');
+	const admin = await fetchAdmin(host);
+	await admin.client.request('admin/accounts/create', { username, password });
+	const signinRes = await signin(host, { username, password });
+
+	return {
+		...signinRes,
+		client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: signinRes.i }),
+		username,
+		password,
+	};
+}
+
+export async function createModerator(host: Host): Promise<LoginUser> {
+	const user = await createAccount(host);
+	const role = await createRole(host, {
+		name: 'Moderator',
+		isModerator: true,
+	});
+	const admin = await fetchAdmin(host);
+	await admin.client.request('admin/roles/assign', { roleId: role.id, userId: user.id });
+	return user;
+}
+
+export async function createRole(
+	host: Host,
+	params: Partial<Misskey.entities.AdminRolesCreateRequest> = {},
+): Promise<Misskey.entities.Role> {
+	const admin = await fetchAdmin(host);
+	return await admin.client.request('admin/roles/create', {
+		name: 'Some role',
+		description: 'Role for testing',
+		color: null,
+		iconUrl: null,
+		target: 'conditional',
+		condFormula: {},
+		isPublic: true,
+		isModerator: false,
+		isAdministrator: false,
+		isExplorable: true,
+		asBadge: false,
+		canEditMembersByModerator: false,
+		displayOrder: 0,
+		policies: {},
+		...params,
+	});
+}
+
+export async function resolveRemoteUser(
+	host: Host,
+	id: string,
+	from: LoginUser,
+): Promise<Misskey.entities.UserDetailedNotMe> {
+	const uri = `https://${host}/users/${id}`;
+	return await from.client.request('ap/show', { uri })
+		.then(res => {
+			strictEqual(res.type, 'User');
+			strictEqual(res.object.uri, uri);
+			return res.object;
+		});
+}
+
+export async function resolveRemoteNote(
+	host: Host,
+	id: string,
+	from: LoginUser,
+): Promise<Misskey.entities.Note> {
+	const uri = `https://${host}/notes/${id}`;
+	return await from.client.request('ap/show', { uri })
+		.then(res => {
+			strictEqual(res.type, 'Note');
+			strictEqual(res.object.uri, uri);
+			return res.object;
+		});
+}
+
+export async function uploadFile(
+	host: Host,
+	user: { i: string },
+	path = '../../test/resources/192.jpg',
+): Promise<Misskey.entities.DriveFile> {
+	const filename = path.split('/').pop() ?? 'untitled';
+	const blob = new Blob([await readFile(join(__dirname, path))]);
+
+	const body = new FormData();
+	body.append('i', user.i);
+	body.append('force', 'true');
+	body.append('file', blob);
+	body.append('name', filename);
+
+	return await fetch(`https://${host}/api/drive/files/create`, { method: 'POST', body })
+		.then(async res => await res.json());
+}
+
+export async function addCustomEmoji(
+	host: Host,
+	param?: Partial<Misskey.entities.AdminEmojiAddRequest>,
+	path?: string,
+): Promise<Misskey.entities.EmojiDetailed> {
+	const admin = await fetchAdmin(host);
+	const name = crypto.randomUUID().replaceAll('-', '');
+	const file = await uploadFile(host, admin, path);
+	return await admin.client.request('admin/emoji/add', { name, fileId: file.id, ...param });
+}
+
+export function deepStrictEqualWithExcludedFields<T>(actual: T, expected: T, excludedFields: (keyof T)[]) {
+	const _actual = structuredClone(actual);
+	const _expected = structuredClone(expected);
+	for (const obj of [_actual, _expected]) {
+		for (const field of excludedFields) {
+			delete obj[field];
+		}
+	}
+	deepStrictEqual(_actual, _expected);
+}
+
+export async function isFired<C extends keyof Misskey.Channels, T extends keyof Misskey.Channels[C]['events']>(
+	host: Host,
+	user: { i: string },
+	channel: C,
+	trigger: () => Promise<unknown>,
+	type: T,
+	// @ts-expect-error TODO: why getting error here?
+	cond: (msg: Parameters<Misskey.Channels[C]['events'][T]>[0]) => boolean,
+	params?: Misskey.Channels[C]['params'],
+): Promise<boolean> {
+	return new Promise<boolean>(async (resolve, reject) => {
+		// @ts-expect-error TODO: why?
+		const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
+		const connection = stream.useChannel(channel, params);
+		connection.on(type as any, ((msg: any) => {
+			if (cond(msg)) {
+				stream.close();
+				clearTimeout(timer);
+				resolve(true);
+			}
+		}) as any);
+
+		let timer: NodeJS.Timeout | undefined;
+
+		await trigger().then(() => {
+			timer = setTimeout(() => {
+				stream.close();
+				resolve(false);
+			}, 500);
+		}).catch(err => {
+			stream.close();
+			clearTimeout(timer);
+			reject(err);
+		});
+	});
+};
+
+export async function isNoteUpdatedEventFired(
+	host: Host,
+	user: { i: string },
+	noteId: string,
+	trigger: () => Promise<unknown>,
+	cond: (msg: Parameters<Misskey.StreamEvents['noteUpdated']>[0]) => boolean,
+): Promise<boolean> {
+	return new Promise<boolean>(async (resolve, reject) => {
+		// @ts-expect-error TODO: why?
+		const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
+		stream.send('s', { id: noteId });
+		stream.on('noteUpdated', msg => {
+			if (cond(msg)) {
+				stream.close();
+				clearTimeout(timer);
+				resolve(true);
+			}
+		});
+
+		let timer: NodeJS.Timeout | undefined;
+
+		await trigger().then(() => {
+			timer = setTimeout(() => {
+				stream.close();
+				resolve(false);
+			}, 500);
+		}).catch(err => {
+			stream.close();
+			clearTimeout(timer);
+			reject(err);
+		});
+	});
+};
+
+export async function assertNotificationReceived(
+	receiverHost: Host,
+	receiver: LoginUser,
+	trigger: () => Promise<unknown>,
+	cond: (notification: Misskey.entities.Notification) => boolean,
+	expect: boolean,
+) {
+	const streamingFired = await isFired(receiverHost, receiver, 'main', trigger, 'notification', cond);
+	strictEqual(streamingFired, expect);
+
+	const endpointFired = await receiver.client.request('i/notifications', {})
+		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+		.then(([notification]) => notification != null ? cond(notification) : false);
+	strictEqual(endpointFired, expect);
+}
diff --git a/packages/backend/test-federation/tsconfig.json b/packages/backend/test-federation/tsconfig.json
new file mode 100644
index 0000000000..3a1cb3b9f3
--- /dev/null
+++ b/packages/backend/test-federation/tsconfig.json
@@ -0,0 +1,114 @@
+{
+	"compilerOptions": {
+		/* Visit https://aka.ms/tsconfig to read more about this file */
+
+		/* Projects */
+		// "incremental": true,                              /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+		// "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
+		// "tsBuildInfoFile": "./.tsbuildinfo",              /* Specify the path to .tsbuildinfo incremental compilation file. */
+		// "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects. */
+		// "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
+		// "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
+
+		/* Language and Environment */
+		"target": "ESNext",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
+		// "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+		// "jsx": "preserve",                                /* Specify what JSX code is generated. */
+		// "experimentalDecorators": true,                   /* Enable experimental support for legacy experimental decorators. */
+		// "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
+		// "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+		// "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+		// "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+		// "reactNamespace": "",                             /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+		// "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
+		// "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
+		// "moduleDetection": "auto",                        /* Control what method is used to detect module-format JS files. */
+
+		/* Modules */
+		"module": "NodeNext",                                /* Specify what module code is generated. */
+		// "rootDir": "./",                                  /* Specify the root folder within your source files. */
+		// "moduleResolution": "node10",                     /* Specify how TypeScript looks up a file from a given module specifier. */
+		// "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
+		// "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
+		// "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
+		// "typeRoots": [],                                  /* Specify multiple folders that act like './node_modules/@types'. */
+		// "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
+		// "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
+		// "moduleSuffixes": [],                             /* List of file name suffixes to search when resolving a module. */
+		// "allowImportingTsExtensions": true,               /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
+		// "resolvePackageJsonExports": true,                /* Use the package.json 'exports' field when resolving package imports. */
+		// "resolvePackageJsonImports": true,                /* Use the package.json 'imports' field when resolving imports. */
+		// "customConditions": [],                           /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
+		// "noUncheckedSideEffectImports": true,             /* Check side effect imports. */
+		// "resolveJsonModule": true,                        /* Enable importing .json files. */
+		// "allowArbitraryExtensions": true,                 /* Enable importing files with any extension, provided a declaration file is present. */
+		// "noResolve": true,                                /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
+
+		/* JavaScript Support */
+		// "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
+		// "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
+		// "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
+
+		/* Emit */
+		// "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+		// "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
+		// "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
+		// "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
+		// "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
+		// "noEmit": true,                                   /* Disable emitting files from a compilation. */
+		// "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
+		"outDir": "./built",                                 /* Specify an output folder for all emitted files. */
+		// "removeComments": true,                           /* Disable emitting comments. */
+		// "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+		// "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+		// "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
+		// "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
+		// "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
+		// "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+		// "newLine": "crlf",                                /* Set the newline character for emitting files. */
+		// "stripInternal": true,                            /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
+		// "noEmitHelpers": true,                            /* Disable generating custom helper functions like '__extends' in compiled output. */
+		// "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
+		// "preserveConstEnums": true,                       /* Disable erasing 'const enum' declarations in generated code. */
+		// "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
+
+		/* Interop Constraints */
+		// "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
+		// "verbatimModuleSyntax": true,                     /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
+		// "isolatedDeclarations": true,                     /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
+		// "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
+		"esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
+		// "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+		"forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
+
+		/* Type Checking */
+		"strict": true,                                      /* Enable all strict type-checking options. */
+		// "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied 'any' type. */
+		// "strictNullChecks": true,                         /* When type checking, take into account 'null' and 'undefined'. */
+		// "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+		// "strictBindCallApply": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
+		// "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
+		// "strictBuiltinIteratorReturn": true,              /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
+		// "noImplicitThis": true,                           /* Enable error reporting when 'this' is given the type 'any'. */
+		// "useUnknownInCatchVariables": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */
+		// "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
+		// "noUnusedLocals": true,                           /* Enable error reporting when local variables aren't read. */
+		// "noUnusedParameters": true,                       /* Raise an error when a function parameter isn't read. */
+		// "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
+		// "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
+		// "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
+		// "noUncheckedIndexedAccess": true,                 /* Add 'undefined' to a type when accessed using an index. */
+		// "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
+		// "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */
+		// "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
+		// "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
+
+		/* Completeness */
+		// "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
+		"skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
+	},
+	"include": [
+		"daemon.ts",
+		"./test/**/*.ts"
+	]
+}
diff --git a/packages/shared/eslint.config.js b/packages/shared/eslint.config.js
index e9d27c4a72..0368d008c0 100644
--- a/packages/shared/eslint.config.js
+++ b/packages/shared/eslint.config.js
@@ -6,6 +6,7 @@ export default [
 	{
 		files: ['**/*.cjs'],
 		languageOptions: {
+			sourceType: 'commonjs',
 			parserOptions: {
 				sourceType: 'commonjs',
 			},
@@ -25,4 +26,10 @@ export default [
 			globals: globals.node,
 		},
 	},
+	{
+		files: ['**/*.js', '**/*.cjs'],
+		rules: {
+			'@typescript-eslint/no-var-requires': 'off',
+		},
+	},
 ];
-- 
GitLab