';
content += `${name}`;
@@ -450,14 +489,13 @@ export class ClientServerService {
//#endregion
const renderBase = async (reply: FastifyReply, data: { [key: string]: any } = {}) => {
- const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=30');
return await reply.view('base', {
- img: meta.bannerUrl,
+ img: this.meta.bannerUrl,
url: this.config.url,
- title: meta.name ?? 'Misskey',
- desc: meta.description,
- ...await this.generateCommonPugData(meta),
+ title: this.meta.name ?? 'Misskey',
+ desc: this.meta.description,
+ ...await this.generateCommonPugData(this.meta),
...data,
});
};
@@ -535,7 +573,6 @@ export class ClientServerService {
if (user != null) {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
- const meta = await this.metaService.fetch();
const me = profile.fields
? profile.fields
.filter(filed => filed.value != null && filed.value.match(/^https?:/))
@@ -551,7 +588,7 @@ export class ClientServerService {
user, profile, me,
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
sub: request.params.sub,
- ...await this.generateCommonPugData(meta),
+ ...await this.generateCommonPugData(this.meta),
});
} else {
// リモートユーザーなので
@@ -581,15 +618,17 @@ export class ClientServerService {
fastify.get<{ Params: { note: string; } }>('/notes/:note', async (request, reply) => {
vary(reply.raw, 'Accept');
- const note = await this.notesRepository.findOneBy({
- id: request.params.note,
- visibility: In(['public', 'home']),
+ const note = await this.notesRepository.findOne({
+ where: {
+ id: request.params.note,
+ visibility: In(['public', 'home']),
+ },
+ relations: ['user'],
});
- if (note) {
+ if (note && !note.user!.requireSigninToViewContents) {
const _note = await this.noteEntityService.pack(note);
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
- const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=15');
if (profile.preventAiLearning) {
reply.header('X-Robots-Tag', 'noimageai');
@@ -601,7 +640,7 @@ export class ClientServerService {
avatarUrl: _note.user.avatarUrl,
// TODO: Let locale changeable by instance setting
summary: getNoteSummary(_note),
- ...await this.generateCommonPugData(meta),
+ ...await this.generateCommonPugData(this.meta),
});
} else {
return await renderBase(reply);
@@ -626,7 +665,6 @@ export class ClientServerService {
if (page) {
const _page = await this.pageEntityService.pack(page);
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: page.userId });
- const meta = await this.metaService.fetch();
if (['public'].includes(page.visibility)) {
reply.header('Cache-Control', 'public, max-age=15');
} else {
@@ -640,7 +678,7 @@ export class ClientServerService {
page: _page,
profile,
avatarUrl: _page.user.avatarUrl,
- ...await this.generateCommonPugData(meta),
+ ...await this.generateCommonPugData(this.meta),
});
} else {
return await renderBase(reply);
@@ -656,7 +694,6 @@ export class ClientServerService {
if (flash) {
const _flash = await this.flashEntityService.pack(flash);
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: flash.userId });
- const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=15');
if (profile.preventAiLearning) {
reply.header('X-Robots-Tag', 'noimageai');
@@ -666,7 +703,7 @@ export class ClientServerService {
flash: _flash,
profile,
avatarUrl: _flash.user.avatarUrl,
- ...await this.generateCommonPugData(meta),
+ ...await this.generateCommonPugData(this.meta),
});
} else {
return await renderBase(reply);
@@ -682,7 +719,6 @@ export class ClientServerService {
if (clip && clip.isPublic) {
const _clip = await this.clipEntityService.pack(clip);
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId });
- const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=15');
if (profile.preventAiLearning) {
reply.header('X-Robots-Tag', 'noimageai');
@@ -692,7 +728,7 @@ export class ClientServerService {
clip: _clip,
profile,
avatarUrl: _clip.user.avatarUrl,
- ...await this.generateCommonPugData(meta),
+ ...await this.generateCommonPugData(this.meta),
});
} else {
return await renderBase(reply);
@@ -706,7 +742,6 @@ export class ClientServerService {
if (post) {
const _post = await this.galleryPostEntityService.pack(post);
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: post.userId });
- const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=15');
if (profile.preventAiLearning) {
reply.header('X-Robots-Tag', 'noimageai');
@@ -716,7 +751,7 @@ export class ClientServerService {
post: _post,
profile,
avatarUrl: _post.user.avatarUrl,
- ...await this.generateCommonPugData(meta),
+ ...await this.generateCommonPugData(this.meta),
});
} else {
return await renderBase(reply);
@@ -731,11 +766,10 @@ export class ClientServerService {
if (channel) {
const _channel = await this.channelEntityService.pack(channel);
- const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=15');
return await reply.view('channel', {
channel: _channel,
- ...await this.generateCommonPugData(meta),
+ ...await this.generateCommonPugData(this.meta),
});
} else {
return await renderBase(reply);
@@ -750,11 +784,28 @@ export class ClientServerService {
if (game) {
const _game = await this.reversiGameEntityService.packDetail(game);
- const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=3600');
return await reply.view('reversi-game', {
game: _game,
- ...await this.generateCommonPugData(meta),
+ ...await this.generateCommonPugData(this.meta),
+ });
+ } else {
+ return await renderBase(reply);
+ }
+ });
+
+ // 個別お知らせページ
+ fastify.get<{ Params: { announcementId: string; } }>('/announcements/:announcementId', async (request, reply) => {
+ const announcement = await this.announcementsRepository.findOneBy({
+ id: request.params.announcementId,
+ });
+
+ if (announcement) {
+ const _announcement = await this.announcementEntityService.pack(announcement);
+ reply.header('Cache-Control', 'public, max-age=3600');
+ return await reply.view('announcement', {
+ announcement: _announcement,
+ ...await this.generateCommonPugData(this.meta),
});
} else {
return await renderBase(reply);
@@ -762,7 +813,7 @@ export class ClientServerService {
});
//#endregion
- //region noindex pages
+ //#region noindex pages
// Tags
fastify.get<{ Params: { clip: string; } }>('/tags/:tag', async (request, reply) => {
return await renderBase(reply, { noindex: true });
@@ -772,21 +823,97 @@ export class ClientServerService {
fastify.get<{ Params: { clip: string; } }>('/user-tags/:tag', async (request, reply) => {
return await renderBase(reply, { noindex: true });
});
- //endregion
+ //#endregion
+
+ //#region embed pages
+ fastify.get<{ Params: { user: string; } }>('/embed/user-timeline/:user', async (request, reply) => {
+ reply.removeHeader('X-Frame-Options');
+
+ const user = await this.usersRepository.findOneBy({
+ id: request.params.user,
+ });
+
+ if (user == null) return;
+ if (user.host != null) return;
+
+ const _user = await this.userEntityService.pack(user);
+
+ reply.header('Cache-Control', 'public, max-age=3600');
+ return await reply.view('base-embed', {
+ title: this.meta.name ?? 'Misskey',
+ ...await this.generateCommonPugData(this.meta),
+ embedCtx: htmlSafeJsonStringify({
+ user: _user,
+ }),
+ });
+ });
+
+ fastify.get<{ Params: { note: string; } }>('/embed/notes/:note', async (request, reply) => {
+ reply.removeHeader('X-Frame-Options');
+
+ const note = await this.notesRepository.findOneBy({
+ id: request.params.note,
+ });
+
+ if (note == null) return;
+ if (note.visibility !== 'public') return;
+ if (note.userHost != null) return;
+
+ const _note = await this.noteEntityService.pack(note, null, { detail: true });
+
+ reply.header('Cache-Control', 'public, max-age=3600');
+ return await reply.view('base-embed', {
+ title: this.meta.name ?? 'Misskey',
+ ...await this.generateCommonPugData(this.meta),
+ embedCtx: htmlSafeJsonStringify({
+ note: _note,
+ }),
+ });
+ });
+
+ fastify.get<{ Params: { clip: string; } }>('/embed/clips/:clip', async (request, reply) => {
+ reply.removeHeader('X-Frame-Options');
+
+ const clip = await this.clipsRepository.findOneBy({
+ id: request.params.clip,
+ });
+
+ if (clip == null) return;
+
+ const _clip = await this.clipEntityService.pack(clip);
+
+ reply.header('Cache-Control', 'public, max-age=3600');
+ return await reply.view('base-embed', {
+ title: this.meta.name ?? 'Misskey',
+ ...await this.generateCommonPugData(this.meta),
+ embedCtx: htmlSafeJsonStringify({
+ clip: _clip,
+ }),
+ });
+ });
+
+ fastify.get('/embed/*', async (request, reply) => {
+ reply.removeHeader('X-Frame-Options');
+
+ reply.header('Cache-Control', 'public, max-age=3600');
+ return await reply.view('base-embed', {
+ title: this.meta.name ?? 'Misskey',
+ ...await this.generateCommonPugData(this.meta),
+ });
+ });
fastify.get('/_info_card_', async (request, reply) => {
- const meta = await this.metaService.fetch(true);
-
reply.removeHeader('X-Frame-Options');
return await reply.view('info-card', {
version: this.config.version,
host: this.config.host,
- meta: meta,
+ meta: this.meta,
originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }),
originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }),
});
});
+ //#endregion
fastify.get('/bios', async (request, reply) => {
return await reply.view('bios', {
diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts
index 8f8f08a305..5d493c2c46 100644
--- a/packages/backend/src/server/web/UrlPreviewService.ts
+++ b/packages/backend/src/server/web/UrlPreviewService.ts
@@ -8,7 +8,6 @@ import { summaly } from '@misskey-dev/summaly';
import { SummalyResult } from '@misskey-dev/summaly/built/summary.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
-import { MetaService } from '@/core/MetaService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import type Logger from '@/logger.js';
import { query } from '@/misc/prelude/url.js';
@@ -26,7 +25,9 @@ export class UrlPreviewService {
@Inject(DI.config)
private config: Config,
- private metaService: MetaService,
+ @Inject(DI.meta)
+ private meta: MiMeta,
+
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
) {
@@ -62,9 +63,7 @@ export class UrlPreviewService {
return;
}
- const meta = await this.metaService.fetch();
-
- if (!meta.urlPreviewEnabled) {
+ if (!this.meta.urlPreviewEnabled) {
reply.code(403);
return {
error: new ApiError({
@@ -75,14 +74,14 @@ export class UrlPreviewService {
};
}
- this.logger.info(meta.urlPreviewSummaryProxyUrl
+ this.logger.info(this.meta.urlPreviewSummaryProxyUrl
? `(Proxy) Getting preview of ${url}@${lang} ...`
: `Getting preview of ${url}@${lang} ...`);
try {
- const summary = meta.urlPreviewSummaryProxyUrl
- ? await this.fetchSummaryFromProxy(url, meta, lang)
- : await this.fetchSummary(url, meta, lang);
+ const summary = this.meta.urlPreviewSummaryProxyUrl
+ ? await this.fetchSummaryFromProxy(url, this.meta, lang)
+ : await this.fetchSummary(url, this.meta, lang);
this.logger.succ(`Got preview of ${url}: ${summary.title}`);
diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js
new file mode 100644
index 0000000000..48d1cd262b
--- /dev/null
+++ b/packages/backend/src/server/web/boot.embed.js
@@ -0,0 +1,219 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+'use strict';
+
+// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
+(async () => {
+ window.onerror = (e) => {
+ console.error(e);
+ renderError('SOMETHING_HAPPENED');
+ };
+ window.onunhandledrejection = (e) => {
+ console.error(e);
+ renderError('SOMETHING_HAPPENED_IN_PROMISE');
+ };
+
+ let forceError = localStorage.getItem('forceError');
+ if (forceError != null) {
+ renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.');
+ return;
+ }
+
+ // パラメータに応じてsplashのスタイルを変更
+ const params = new URLSearchParams(location.search);
+ if (params.has('rounded') && params.get('rounded') === 'false') {
+ document.documentElement.classList.add('norounded');
+ }
+ if (params.has('border') && params.get('border') === 'false') {
+ document.documentElement.classList.add('noborder');
+ }
+
+ //#region Detect language & fetch translations
+ if (!localStorage.hasOwnProperty('locale')) {
+ const supportedLangs = LANGS;
+ let lang = localStorage.getItem('lang');
+ if (lang == null || !supportedLangs.includes(lang)) {
+ if (supportedLangs.includes(navigator.language)) {
+ lang = navigator.language;
+ } else {
+ lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
+
+ // Fallback
+ if (lang == null) lang = 'en-US';
+ }
+ }
+
+ const metaRes = await window.fetch('/api/meta', {
+ method: 'POST',
+ body: JSON.stringify({}),
+ credentials: 'omit',
+ cache: 'no-cache',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ if (metaRes.status !== 200) {
+ renderError('META_FETCH');
+ return;
+ }
+ const meta = await metaRes.json();
+ const v = meta.version;
+ if (v == null) {
+ renderError('META_FETCH_V');
+ return;
+ }
+
+ // for https://github.com/misskey-dev/misskey/issues/10202
+ if (lang == null || lang.toString == null || lang.toString() === 'null') {
+ console.error('invalid lang value detected!!!', typeof lang, lang);
+ lang = 'en-US';
+ }
+
+ const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
+ if (localRes.status === 200) {
+ localStorage.setItem('lang', lang);
+ localStorage.setItem('locale', await localRes.text());
+ localStorage.setItem('localeVersion', v);
+ } else {
+ renderError('LOCALE_FETCH');
+ return;
+ }
+ }
+ //#endregion
+
+ //#region Script
+ async function importAppScript() {
+ await import(`/embed_vite/${CLIENT_ENTRY}`)
+ .catch(async e => {
+ console.error(e);
+ renderError('APP_IMPORT');
+ });
+ }
+
+ // タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある
+ if (document.readyState !== 'loading') {
+ importAppScript();
+ } else {
+ window.addEventListener('DOMContentLoaded', () => {
+ importAppScript();
+ });
+ }
+ //#endregion
+
+ async function addStyle(styleText) {
+ let css = document.createElement('style');
+ css.appendChild(document.createTextNode(styleText));
+ document.head.appendChild(css);
+ }
+
+ async function renderError(code) {
+ // Cannot set property 'innerHTML' of null を回避
+ if (document.readyState === 'loading') {
+ await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
+ }
+ document.body.innerHTML = `
+ 読み込みに失敗しました
+ Failed to initialize Misskey
+ Error Code: ${code}
+ `;
+ addStyle(`
+ #misskey_app,
+ #splash {
+ display: none !important;
+ }
+
+ html,
+ body {
+ margin: 0;
+ }
+
+ body {
+ position: relative;
+ color: #dee7e4;
+ font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
+ line-height: 1.35;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ margin: 0;
+ padding: 24px;
+ box-sizing: border-box;
+ overflow: hidden;
+
+ border-radius: var(--radius, 12px);
+ border: 1px solid rgba(231, 255, 251, 0.14);
+ }
+
+ body::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: #192320;
+ border-radius: var(--radius, 12px);
+ z-index: -1;
+ }
+
+ html.embed.norounded body,
+ html.embed.norounded body::before {
+ border-radius: 0;
+ }
+
+ html.embed.noborder body {
+ border: none;
+ }
+
+ .icon {
+ max-width: 60px;
+ width: 100%;
+ height: auto;
+ margin-bottom: 20px;
+ color: #dec340;
+ }
+
+ .message {
+ text-align: center;
+ font-size: 20px;
+ font-weight: 700;
+ margin-bottom: 20px;
+ }
+
+ .submessage {
+ text-align: center;
+ font-size: 90%;
+ margin-bottom: 7.5px;
+ }
+
+ .submessage:last-of-type {
+ margin-bottom: 20px;
+ }
+
+ button {
+ padding: 7px 14px;
+ min-width: 100px;
+ font-weight: 700;
+ font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
+ line-height: 1.35;
+ border-radius: 99rem;
+ background-color: #b4e900;
+ color: #192320;
+ border: none;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+ }
+
+ button:hover {
+ background-color: #c6ff03;
+ }`);
+ }
+})();
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js
index 5283596316..a04640d993 100644
--- a/packages/backend/src/server/web/boot.js
+++ b/packages/backend/src/server/web/boot.js
@@ -3,17 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-/**
- * BOOT LOADER
- * サーバーからレスポンスされるHTMLに埋め込まれるスクリプトで、以下の役割を持ちます。
- * - 翻訳ファイルをフェッチする。
- * - バージョンに基づいて適切なメインスクリプトを読み込む。
- * - キャッシュされたコンパイル済みテーマを適用する。
- * - クライアントの設定値に基づいて対応するHTMLクラス等を設定する。
- * テーマをこの段階で設定するのは、メインスクリプトが読み込まれる間もテーマを適用したいためです。
- * 注: webpackは介さないため、このファイルではrequireやimportは使えません。
- */
-
'use strict';
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
@@ -109,7 +98,7 @@
const theme = localStorage.getItem('theme');
if (theme) {
for (const [k, v] of Object.entries(JSON.parse(theme))) {
- document.documentElement.style.setProperty(`--${k}`, v.toString());
+ document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
// HTMLの theme-color 適用
if (k === 'htmlThemeColor') {
diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css
index e4723c24fd..5d81f2bed0 100644
--- a/packages/backend/src/server/web/style.css
+++ b/packages/backend/src/server/web/style.css
@@ -5,8 +5,8 @@
*/
html {
- background-color: var(--bg);
- color: var(--fg);
+ background-color: var(--MI_THEME-bg);
+ color: var(--MI_THEME-fg);
}
#splash {
@@ -17,7 +17,7 @@ html {
width: 100vw;
height: 100vh;
cursor: wait;
- background-color: var(--bg);
+ background-color: var(--MI_THEME-bg);
opacity: 1;
transition: opacity 0.5s ease;
}
@@ -45,8 +45,9 @@ html {
width: 28px;
height: 28px;
transform: translateY(70px);
- color: var(--accent);
+ color: var(--MI_THEME-accent);
}
+
#splashSpinner > .spinner {
position: absolute;
top: 0;
diff --git a/packages/backend/src/server/web/style.embed.css b/packages/backend/src/server/web/style.embed.css
new file mode 100644
index 0000000000..5e8786cc4e
--- /dev/null
+++ b/packages/backend/src/server/web/style.embed.css
@@ -0,0 +1,99 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+html {
+ background-color: var(--MI_THEME-bg);
+ color: var(--MI_THEME-fg);
+}
+
+html.embed {
+ box-sizing: border-box;
+ background-color: transparent;
+ color-scheme: light dark;
+ max-width: 500px;
+}
+
+#splash {
+ position: fixed;
+ z-index: 10000;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ cursor: wait;
+ background-color: var(--MI_THEME-bg);
+ opacity: 1;
+ transition: opacity 0.5s ease;
+}
+
+html.embed #splash {
+ box-sizing: border-box;
+ min-height: 300px;
+ border-radius: var(--radius, 12px);
+ border: 1px solid var(--MI_THEME-divider, #e8e8e8);
+}
+
+html.embed.norounded #splash {
+ border-radius: 0;
+}
+
+html.embed.noborder #splash {
+ border: none;
+}
+
+#splashIcon {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ margin: auto;
+ width: 64px;
+ height: 64px;
+ pointer-events: none;
+}
+
+#splashSpinner {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ margin: auto;
+ display: inline-block;
+ width: 28px;
+ height: 28px;
+ transform: translateY(70px);
+ color: var(--MI_THEME-accent);
+}
+
+#splashSpinner > .spinner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 28px;
+ height: 28px;
+ fill-rule: evenodd;
+ clip-rule: evenodd;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-miterlimit: 1.5;
+}
+#splashSpinner > .spinner.bg {
+ opacity: 0.275;
+}
+#splashSpinner > .spinner.fg {
+ animation: splashSpinner 0.5s linear infinite;
+}
+
+@keyframes splashSpinner {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/packages/backend/src/server/web/views/announcement.pug b/packages/backend/src/server/web/views/announcement.pug
new file mode 100644
index 0000000000..7a4052e8a4
--- /dev/null
+++ b/packages/backend/src/server/web/views/announcement.pug
@@ -0,0 +1,21 @@
+extends ./base
+
+block vars
+ - const title = announcement.title;
+ - const description = announcement.text.length > 100 ? announcement.text.slice(0, 100) + '…' : announcement.text;
+ - const url = `${config.url}/announcements/${announcement.id}`;
+
+block title
+ = `${title} | ${instanceName}`
+
+block desc
+ meta(name='description' content=description)
+
+block og
+ meta(property='og:type' content='article')
+ meta(property='og:title' content= title)
+ meta(property='og:description' content= description)
+ meta(property='og:url' content= url)
+ if announcement.imageUrl
+ meta(property='og:image' content=announcement.imageUrl)
+ meta(property='twitter:card' content='summary_large_image')
diff --git a/packages/backend/src/server/web/views/base-embed.pug b/packages/backend/src/server/web/views/base-embed.pug
new file mode 100644
index 0000000000..baa0909676
--- /dev/null
+++ b/packages/backend/src/server/web/views/base-embed.pug
@@ -0,0 +1,72 @@
+block vars
+
+block loadClientEntry
+ - const entry = config.frontendEmbedEntry;
+
+doctype html
+
+html(class='embed')
+
+ head
+ meta(charset='utf-8')
+ meta(name='application-name' content='Misskey')
+ meta(name='referrer' content='origin')
+ meta(name='theme-color' content= themeColor || '#86b300')
+ meta(name='theme-color-orig' content= themeColor || '#86b300')
+ meta(property='og:site_name' content= instanceName || 'Misskey')
+ meta(property='instance_url' content= instanceUrl)
+ meta(name='viewport' content='width=device-width, initial-scale=1')
+ meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
+ link(rel='icon' href= icon || '/favicon.ico')
+ link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
+ link(rel='modulepreload' href=`/embed_vite/${entry.file}`)
+
+ if !config.frontendEmbedManifestExists
+ script(type="module" src="/embed_vite/@vite/client")
+
+ if Array.isArray(entry.css)
+ each href in entry.css
+ link(rel='stylesheet' href=`/embed_vite/${href}`)
+
+ title
+ block title
+ = title || 'Misskey'
+
+ block meta
+ meta(name='robots' content='noindex')
+
+ style
+ include ../style.embed.css
+
+ script.
+ var VERSION = "#{version}";
+ var CLIENT_ENTRY = "#{entry.file}";
+
+ script(type='application/json' id='misskey_meta' data-generated-at=now)
+ != metaJson
+
+ script(type='application/json' id='misskey_embedCtx' data-generated-at=now)
+ != embedCtx
+
+ script
+ include ../boot.embed.js
+
+ body
+ noscript: p
+ | JavaScriptを有効にしてください
+ br
+ | Please turn on your JavaScript
+ div#splash
+ img#splashIcon(src= icon || '/static-assets/splash.png')
+ div#splashSpinner
+
+
+ block content
diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug
index da6d1eafd3..280a5923c2 100644
--- a/packages/backend/src/server/web/views/base.pug
+++ b/packages/backend/src/server/web/views/base.pug
@@ -1,7 +1,8 @@
block vars
block loadClientEntry
- - const clientEntry = config.clientEntry;
+ - const entry = config.frontendEntry;
+ - const baseUrl = config.url;
doctype html
@@ -32,17 +33,17 @@ html
link(rel='icon' href= icon || '/favicon.ico')
link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
link(rel='manifest' href='/manifest.json')
- link(rel='search' type='application/opensearchdescription+xml' title=(title || "Misskey") href=`${url}/opensearch.xml`)
+ link(rel='search' type='application/opensearchdescription+xml' title=(title || "Misskey") href=`${baseUrl}/opensearch.xml`)
link(rel='prefetch' href=serverErrorImageUrl)
link(rel='prefetch' href=infoImageUrl)
link(rel='prefetch' href=notFoundImageUrl)
- link(rel='modulepreload' href=`/vite/${clientEntry.file}`)
+ link(rel='modulepreload' href=`/vite/${entry.file}`)
- if !config.clientManifestExists
+ if !config.frontendManifestExists
script(type="module" src="/vite/@vite/client")
- if Array.isArray(clientEntry.css)
- each href in clientEntry.css
+ if Array.isArray(entry.css)
+ each href in entry.css
link(rel='stylesheet' href=`/vite/${href}`)
title
@@ -68,7 +69,7 @@ html
script.
var VERSION = "#{version}";
- var CLIENT_ENTRY = "#{clientEntry.file}";
+ var CLIENT_ENTRY = "#{entry.file}";
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index e852cf5ae2..df3cfee171 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -16,6 +16,8 @@
* followRequestAccepted - 自分の送ったフォローリクエストが承認された
* roleAssigned - ロールが付与された
* achievementEarned - 実績を獲得
+ * exportCompleted - エクスポートが完了
+ * login - ログイン
* app - アプリ通知
* test - テスト通知(サーバー側)
*/
@@ -32,6 +34,8 @@ export const notificationTypes = [
'followRequestAccepted',
'roleAssigned',
'achievementEarned',
+ 'exportCompleted',
+ 'login',
'app',
'test',
] as const;
@@ -51,6 +55,20 @@ export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;
export const followingVisibilities = ['public', 'followers', 'private'] as const;
export const followersVisibilities = ['public', 'followers', 'private'] as const;
+/**
+ * ユーザーがエクスポートできるものの種類
+ *
+ * (主にエクスポート完了通知で使用するものであり、既存のDBの名称等と必ずしも一致しない)
+ */
+export const userExportableEntities = ['antenna', 'blocking', 'clip', 'customEmoji', 'favorite', 'following', 'muting', 'note', 'userList'] as const;
+
+/**
+ * ユーザーがインポートできるものの種類
+ *
+ * (主にインポート完了通知で使用するものであり、既存のDBの名称等と必ずしも一致しない)
+ */
+export const userImportableEntities = ['antenna', 'blocking', 'customEmoji', 'following', 'muting', 'userList'] as const;
+
export const moderationLogTypes = [
'updateServerSettings',
'suspend',
@@ -81,6 +99,8 @@ export const moderationLogTypes = [
'markSensitiveDriveFile',
'unmarkSensitiveDriveFile',
'resolveAbuseReport',
+ 'forwardAbuseReport',
+ 'updateAbuseReportNote',
'createInvitation',
'createAd',
'updateAd',
@@ -249,7 +269,18 @@ export type ModerationLogPayloads = {
resolveAbuseReport: {
reportId: string;
report: any;
- forwarded: boolean;
+ forwarded?: boolean;
+ resolvedAs?: string | null;
+ };
+ forwardAbuseReport: {
+ reportId: string;
+ report: any;
+ };
+ updateAbuseReportNote: {
+ reportId: string;
+ report: any;
+ before: string;
+ after: string;
};
createInvitation: {
invitations: any[];
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([
+ ['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(
+ 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..093277cdb4
--- /dev/null
+++ b/packages/backend/test-federation/test/utils.ts
@@ -0,0 +1,307 @@
+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();
+
+await Promise.all([
+ fetchAdmin('a.test'),
+ fetchAdmin('b.test'),
+]);
+
+type SigninResponse = Omit;
+
+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>;
+
+type Host = 'a.test' | 'b.test';
+
+export async function sleep(ms = 200): Promise {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+async function signin(
+ host: Host,
+ params: Misskey.entities.SigninFlowRequest,
+): Promise {
+ // 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 = {},
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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,
+ path?: string,
+): Promise {
+ 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(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(
+ host: Host,
+ user: { i: string },
+ channel: C,
+ trigger: () => Promise,
+ type: T,
+ // @ts-expect-error TODO: why getting error here?
+ cond: (msg: Parameters[0]) => boolean,
+ params?: Misskey.Channels[C]['params'],
+): Promise {
+ return new Promise(async (resolve, reject) => {
+ 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,
+ cond: (msg: Parameters[0]) => boolean,
+): Promise {
+ return new Promise(async (resolve, reject) => {
+ 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,
+ 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 ''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/backend/test-server/entry.ts b/packages/backend/test-server/entry.ts
index 866a7e1f5b..04bf62d209 100644
--- a/packages/backend/test-server/entry.ts
+++ b/packages/backend/test-server/entry.ts
@@ -6,12 +6,16 @@ import { MainModule } from '@/MainModule.js';
import { ServerService } from '@/server/ServerService.js';
import { loadConfig } from '@/config.js';
import { NestLogger } from '@/NestLogger.js';
+import { INestApplicationContext } from '@nestjs/common';
const config = loadConfig();
const originEnv = JSON.stringify(process.env);
process.env.NODE_ENV = 'test';
+let app: INestApplicationContext;
+let serverService: ServerService;
+
/**
* テスト用のサーバインスタンスを起動する
*/
@@ -20,10 +24,10 @@ async function launch() {
console.log('starting application...');
- const app = await NestFactory.createApplicationContext(MainModule, {
+ app = await NestFactory.createApplicationContext(MainModule, {
logger: new NestLogger(),
});
- const serverService = app.get(ServerService);
+ serverService = app.get(ServerService);
await serverService.launch();
await startControllerEndpoints();
@@ -71,6 +75,20 @@ async function startControllerEndpoints(port = config.port + 1000) {
fastify.post<{ Body: { key?: string, value?: string } }>('/env-reset', async (req, res) => {
process.env = JSON.parse(originEnv);
+
+ await serverService.dispose();
+ await app.close();
+
+ await killTestServer();
+
+ console.log('starting application...');
+
+ app = await NestFactory.createApplicationContext(MainModule, {
+ logger: new NestLogger(),
+ });
+ serverService = app.get(ServerService);
+ await serverService.launch();
+
res.code(200).send({ success: true });
});
diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts
index 06548fa7da..48e1bababb 100644
--- a/packages/backend/test/e2e/2fa.ts
+++ b/packages/backend/test/e2e/2fa.ts
@@ -136,13 +136,7 @@ describe('2要素認証', () => {
keyName: string,
credentialId: Buffer,
requestOptions: PublicKeyCredentialRequestOptionsJSON,
- }): {
- username: string,
- password: string,
- credential: AuthenticationResponseJSON,
- 'g-recaptcha-response'?: string | null,
- 'hcaptcha-response'?: string | null,
- } => {
+ }): misskey.entities.SigninFlowRequest => {
// AuthenticatorAssertionResponse.authenticatorData
// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData
const authenticatorData = Buffer.concat([
@@ -202,17 +196,21 @@ describe('2要素認証', () => {
}, alice);
assert.strictEqual(doneResponse.status, 200);
- const usersShowResponse = await api('users/show', {
- username,
- }, alice);
- assert.strictEqual(usersShowResponse.status, 200);
- assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true);
+ const signinWithoutTokenResponse = await api('signin-flow', {
+ ...signinParam(),
+ });
+ assert.strictEqual(signinWithoutTokenResponse.status, 200);
+ assert.deepStrictEqual(signinWithoutTokenResponse.body, {
+ finished: false,
+ next: 'totp',
+ });
- const signinResponse = await api('signin', {
+ const signinResponse = await api('signin-flow', {
...signinParam(),
token: otpToken(registerResponse.body.secret),
});
assert.strictEqual(signinResponse.status, 200);
+ assert.strictEqual(signinResponse.body.finished, true);
assert.notEqual(signinResponse.body.i, undefined);
// 後片付け
@@ -253,27 +251,23 @@ describe('2要素認証', () => {
assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url'));
assert.strictEqual(keyDoneResponse.body.name, keyName);
- const usersShowResponse = await api('users/show', {
- username,
- });
- assert.strictEqual(usersShowResponse.status, 200);
- assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, true);
-
- const signinResponse = await api('signin', {
+ const signinResponse = await api('signin-flow', {
...signinParam(),
});
assert.strictEqual(signinResponse.status, 200);
- assert.strictEqual(signinResponse.body.i, undefined);
- assert.notEqual((signinResponse.body as unknown as { challenge: unknown | undefined }).challenge, undefined);
- assert.notEqual((signinResponse.body as unknown as { allowCredentials: unknown | undefined }).allowCredentials, undefined);
- assert.strictEqual((signinResponse.body as unknown as { allowCredentials: {id: string}[] }).allowCredentials[0].id, credentialId.toString('base64url'));
+ assert.strictEqual(signinResponse.body.finished, false);
+ assert.strictEqual(signinResponse.body.next, 'passkey');
+ assert.notEqual(signinResponse.body.authRequest.challenge, undefined);
+ assert.notEqual(signinResponse.body.authRequest.allowCredentials, undefined);
+ assert.strictEqual(signinResponse.body.authRequest.allowCredentials && signinResponse.body.authRequest.allowCredentials[0]?.id, credentialId.toString('base64url'));
- const signinResponse2 = await api('signin', signinWithSecurityKeyParam({
+ const signinResponse2 = await api('signin-flow', signinWithSecurityKeyParam({
keyName,
credentialId,
- requestOptions: signinResponse.body,
- } as any));
+ requestOptions: signinResponse.body.authRequest,
+ }));
assert.strictEqual(signinResponse2.status, 200);
+ assert.strictEqual(signinResponse2.body.finished, true);
assert.notEqual(signinResponse2.body.i, undefined);
// 後片付け
@@ -315,28 +309,30 @@ describe('2要素認証', () => {
}, alice);
assert.strictEqual(passwordLessResponse.status, 204);
- const usersShowResponse = await api('users/show', {
- username,
- });
- assert.strictEqual(usersShowResponse.status, 200);
- assert.strictEqual((usersShowResponse.body as unknown as { usePasswordLessLogin: boolean }).usePasswordLessLogin, true);
+ const iResponse = await api('i', {}, alice);
+ assert.strictEqual(iResponse.status, 200);
+ assert.strictEqual(iResponse.body.usePasswordLessLogin, true);
- const signinResponse = await api('signin', {
+ const signinResponse = await api('signin-flow', {
...signinParam(),
password: '',
});
assert.strictEqual(signinResponse.status, 200);
- assert.strictEqual(signinResponse.body.i, undefined);
+ assert.strictEqual(signinResponse.body.finished, false);
+ assert.strictEqual(signinResponse.body.next, 'passkey');
+ assert.notEqual(signinResponse.body.authRequest.challenge, undefined);
+ assert.notEqual(signinResponse.body.authRequest.allowCredentials, undefined);
- const signinResponse2 = await api('signin', {
+ const signinResponse2 = await api('signin-flow', {
...signinWithSecurityKeyParam({
keyName,
credentialId,
- requestOptions: signinResponse.body,
+ requestOptions: signinResponse.body.authRequest,
} as any),
password: '',
});
assert.strictEqual(signinResponse2.status, 200);
+ assert.strictEqual(signinResponse2.body.finished, true);
assert.notEqual(signinResponse2.body.i, undefined);
// 後片付け
@@ -424,11 +420,11 @@ describe('2要素認証', () => {
assert.strictEqual(keyDoneResponse.status, 200);
// テストの実行順によっては複数残ってるので全部消す
- const iResponse = await api('i', {
+ const beforeIResponse = await api('i', {
}, alice);
- assert.strictEqual(iResponse.status, 200);
- assert.ok(iResponse.body.securityKeysList);
- for (const key of iResponse.body.securityKeysList) {
+ assert.strictEqual(beforeIResponse.status, 200);
+ assert.ok(beforeIResponse.body.securityKeysList);
+ for (const key of beforeIResponse.body.securityKeysList) {
const removeKeyResponse = await api('i/2fa/remove-key', {
token: otpToken(registerResponse.body.secret),
password,
@@ -437,17 +433,16 @@ describe('2要素認証', () => {
assert.strictEqual(removeKeyResponse.status, 200);
}
- const usersShowResponse = await api('users/show', {
- username,
- });
- assert.strictEqual(usersShowResponse.status, 200);
- assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, false);
+ const afterIResponse = await api('i', {}, alice);
+ assert.strictEqual(afterIResponse.status, 200);
+ assert.strictEqual(afterIResponse.body.securityKeys, false);
- const signinResponse = await api('signin', {
+ const signinResponse = await api('signin-flow', {
...signinParam(),
token: otpToken(registerResponse.body.secret),
});
assert.strictEqual(signinResponse.status, 200);
+ assert.strictEqual(signinResponse.body.finished, true);
assert.notEqual(signinResponse.body.i, undefined);
// 後片付け
@@ -468,11 +463,9 @@ describe('2要素認証', () => {
}, alice);
assert.strictEqual(doneResponse.status, 200);
- const usersShowResponse = await api('users/show', {
- username,
- });
- assert.strictEqual(usersShowResponse.status, 200);
- assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true);
+ const iResponse = await api('i', {}, alice);
+ assert.strictEqual(iResponse.status, 200);
+ assert.strictEqual(iResponse.body.twoFactorEnabled, true);
const unregisterResponse = await api('i/2fa/unregister', {
token: otpToken(registerResponse.body.secret),
@@ -480,10 +473,11 @@ describe('2要素認証', () => {
}, alice);
assert.strictEqual(unregisterResponse.status, 204);
- const signinResponse = await api('signin', {
+ const signinResponse = await api('signin-flow', {
...signinParam(),
});
assert.strictEqual(signinResponse.status, 200);
+ assert.strictEqual(signinResponse.body.finished, true);
assert.notEqual(signinResponse.body.i, undefined);
// 後片付け
diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts
index 6ac14cd8dc..a544db955a 100644
--- a/packages/backend/test/e2e/antennas.ts
+++ b/packages/backend/test/e2e/antennas.ts
@@ -228,6 +228,17 @@ describe('アンテナ', () => {
assert.deepStrictEqual(response, expected);
});
+ test('を作成する時キーワードが指定されていないとエラーになる', async () => {
+ await failedApiCall({
+ endpoint: 'antennas/create',
+ parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] },
+ user: alice
+ }, {
+ status: 400,
+ code: 'EMPTY_KEYWORD',
+ id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a'
+ })
+ });
//#endregion
//#region 更新(antennas/update)
@@ -255,6 +266,18 @@ describe('アンテナ', () => {
id: '1c6b35c9-943e-48c2-81e4-2844989407f7',
});
});
+ test('を変更する時キーワードが指定されていないとエラーになる', async () => {
+ const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
+ await failedApiCall({
+ endpoint: 'antennas/update',
+ parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] },
+ user: alice
+ }, {
+ status: 400,
+ code: 'EMPTY_KEYWORD',
+ id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4'
+ })
+ });
//#endregion
//#region 表示(antennas/show)
diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts
index 5aaec7f6f9..b91d77c398 100644
--- a/packages/backend/test/e2e/endpoints.ts
+++ b/packages/backend/test/e2e/endpoints.ts
@@ -66,9 +66,9 @@ describe('Endpoints', () => {
});
});
- describe('signin', () => {
+ describe('signin-flow', () => {
test('間違ったパスワードでサインインできない', async () => {
- const res = await api('signin', {
+ const res = await api('signin-flow', {
username: 'test1',
password: 'bar',
});
@@ -77,7 +77,7 @@ describe('Endpoints', () => {
});
test('クエリをインジェクションできない', async () => {
- const res = await api('signin', {
+ const res = await api('signin-flow', {
username: 'test1',
// @ts-expect-error password must be string
password: {
@@ -89,7 +89,7 @@ describe('Endpoints', () => {
});
test('正しい情報でサインインできる', async () => {
- const res = await api('signin', {
+ const res = await api('signin-flow', {
username: 'test1',
password: 'test1',
});
diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts
index 7efd688ec2..8ea4cb9800 100644
--- a/packages/backend/test/e2e/fetch-resource.ts
+++ b/packages/backend/test/e2e/fetch-resource.ts
@@ -230,6 +230,7 @@ describe('Webリソース', () => {
path: path('xxxxxxxxxx'),
type: HTML,
}));
+ test.todo('HTMLとしてGETできる。(リモートユーザーでもリダイレクトせず)');
});
describe.each([
@@ -249,6 +250,7 @@ describe('Webリソース', () => {
path: path('xxxxxxxxxx'),
accept,
}));
+ test.todo('はオリジナルにリダイレクトされる。(リモートユーザー)');
});
});
diff --git a/packages/backend/test/e2e/synalio/abuse-report.ts b/packages/backend/test/e2e/synalio/abuse-report.ts
index 6ce6e47781..c98d199f35 100644
--- a/packages/backend/test/e2e/synalio/abuse-report.ts
+++ b/packages/backend/test/e2e/synalio/abuse-report.ts
@@ -157,7 +157,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: webhookBody1.body.id,
- forward: false,
}, admin);
});
@@ -214,7 +213,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: abuseReportId,
- forward: false,
}, admin);
});
@@ -257,7 +255,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: webhookBody1.body.id,
- forward: false,
}, admin);
}).catch(e => e.message);
@@ -288,7 +285,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: abuseReportId,
- forward: false,
}, admin);
}).catch(e => e.message);
@@ -319,7 +315,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: abuseReportId,
- forward: false,
}, admin);
}).catch(e => e.message);
@@ -350,7 +345,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: abuseReportId,
- forward: false,
}, admin);
}).catch(e => e.message);
diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts
index 61fd759932..822ca14ae6 100644
--- a/packages/backend/test/e2e/users.ts
+++ b/packages/backend/test/e2e/users.ts
@@ -7,9 +7,9 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { inspect } from 'node:util';
-import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import { api, post, role, signup, successfulApiCall, uploadFile } from '../utils.js';
import type * as misskey from 'misskey-js';
+import { DEFAULT_POLICIES } from '@/core/RoleService.js';
describe('ユーザー', () => {
// エンティティとしてのユーザーを主眼においたテストを記述する
@@ -83,9 +83,6 @@ describe('ユーザー', () => {
publicReactions: user.publicReactions,
followingVisibility: user.followingVisibility,
followersVisibility: user.followersVisibility,
- twoFactorEnabled: user.twoFactorEnabled,
- usePasswordLessLogin: user.usePasswordLessLogin,
- securityKeys: user.securityKeys,
roles: user.roles,
memo: user.memo,
});
@@ -105,6 +102,7 @@ describe('ユーザー', () => {
isRenoteMuted: user.isRenoteMuted ?? false,
notify: user.notify ?? 'none',
withReplies: user.withReplies ?? false,
+ followedMessage: user.isFollowing ? (user.followedMessage ?? null) : undefined,
});
};
@@ -114,6 +112,7 @@ describe('ユーザー', () => {
...userDetailedNotMe(user),
avatarId: user.avatarId,
bannerId: user.bannerId,
+ followedMessage: user.followedMessage,
isModerator: user.isModerator,
isAdmin: user.isAdmin,
injectFeaturedNote: user.injectFeaturedNote,
@@ -147,6 +146,9 @@ describe('ユーザー', () => {
achievements: user.achievements,
loggedInDays: user.loggedInDays,
policies: user.policies,
+ twoFactorEnabled: user.twoFactorEnabled,
+ usePasswordLessLogin: user.usePasswordLessLogin,
+ securityKeys: user.securityKeys,
...(security ? {
email: user.email,
emailVerified: user.emailVerified,
@@ -341,15 +343,13 @@ describe('ユーザー', () => {
assert.strictEqual(response.publicReactions, true);
assert.strictEqual(response.followingVisibility, 'public');
assert.strictEqual(response.followersVisibility, 'public');
- assert.strictEqual(response.twoFactorEnabled, false);
- assert.strictEqual(response.usePasswordLessLogin, false);
- assert.strictEqual(response.securityKeys, false);
assert.deepStrictEqual(response.roles, []);
assert.strictEqual(response.memo, null);
// MeDetailedOnly
assert.strictEqual(response.avatarId, null);
assert.strictEqual(response.bannerId, null);
+ assert.strictEqual(response.followedMessage, null);
assert.strictEqual(response.isModerator, false);
assert.strictEqual(response.isAdmin, false);
assert.strictEqual(response.injectFeaturedNote, true);
@@ -382,6 +382,9 @@ describe('ユーザー', () => {
assert.deepStrictEqual(response.achievements, []);
assert.deepStrictEqual(response.loggedInDays, 0);
assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
+ assert.strictEqual(response.twoFactorEnabled, false);
+ assert.strictEqual(response.usePasswordLessLogin, false);
+ assert.strictEqual(response.securityKeys, false);
assert.notStrictEqual(response.email, undefined);
assert.strictEqual(response.emailVerified, false);
assert.deepStrictEqual(response.securityKeysList, []);
@@ -413,6 +416,8 @@ describe('ユーザー', () => {
{ parameters: () => ({ description: 'x'.repeat(1500) }) },
{ parameters: () => ({ description: 'x' }) },
{ parameters: () => ({ description: 'My description' }) },
+ { parameters: () => ({ followedMessage: null }) },
+ { parameters: () => ({ followedMessage: 'Thank you' }) },
{ parameters: () => ({ location: null }) },
{ parameters: () => ({ location: 'x'.repeat(50) }) },
{ parameters: () => ({ location: 'x' }) },
@@ -613,6 +618,9 @@ describe('ユーザー', () => {
{ label: 'Moderatorになっている', user: () => userModerator, me: () => userModerator, selector: (user: misskey.entities.MeDetailed) => user.isModerator },
// @ts-expect-error UserDetailedNotMe doesn't include isModerator
{ label: '自分以外から見たときはModeratorか判定できない', user: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.isModerator, expected: () => undefined },
+ { label: '自分から見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => alice, selector: (user: misskey.entities.MeDetailed) => user.twoFactorEnabled, expected: () => false },
+ { label: '自分以外から見た場合に二要素認証関連のプロパティがセットされていない', user: () => alice, me: () => bob, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => undefined },
+ { label: 'モデレーターから見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => false },
{ label: 'サイレンスになっている', user: () => userSilenced, selector: (user: misskey.entities.UserDetailed) => user.isSilenced },
// FIXME: 落ちる
//{ label: 'サスペンドになっている', user: () => userSuspended, selector: (user: misskey.entities.UserDetailed) => user.isSuspended },
diff --git a/packages/backend/test/jest.setup.ts b/packages/backend/test/jest.setup.ts
index 861bc6db66..7c6dd6a55f 100644
--- a/packages/backend/test/jest.setup.ts
+++ b/packages/backend/test/jest.setup.ts
@@ -6,8 +6,6 @@
import { initTestDb, sendEnvResetRequest } from './utils.js';
beforeAll(async () => {
- await Promise.all([
- initTestDb(false),
- sendEnvResetRequest(),
- ]);
+ await initTestDb(false);
+ await sendEnvResetRequest();
});
diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts
index 3c7e796700..c8f3db8aac 100644
--- a/packages/backend/test/misc/mock-resolver.ts
+++ b/packages/backend/test/misc/mock-resolver.ts
@@ -17,6 +17,7 @@ import type { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import type {
FollowRequestsRepository,
+ MiMeta,
NoteReactionsRepository,
NotesRepository,
PollsRepository,
@@ -35,6 +36,7 @@ export class MockResolver extends Resolver {
constructor(loggerService: LoggerService) {
super(
{} as Config,
+ {} as MiMeta,
{} as UsersRepository,
{} as NotesRepository,
{} as PollsRepository,
@@ -42,7 +44,6 @@ export class MockResolver extends Resolver {
{} as FollowRequestsRepository,
{} as UtilityService,
{} as InstanceActorService,
- {} as MetaService,
{} as ApRequestService,
{} as HttpRequestService,
{} as ApRendererService,
diff --git a/packages/backend/test/unit/AbuseReportNotificationService.ts b/packages/backend/test/unit/AbuseReportNotificationService.ts
index e971659070..235af29f0d 100644
--- a/packages/backend/test/unit/AbuseReportNotificationService.ts
+++ b/packages/backend/test/unit/AbuseReportNotificationService.ts
@@ -5,6 +5,7 @@
import { jest } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
+import { randomString } from '../utils.js';
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
import {
AbuseReportNotificationRecipientRepository,
@@ -25,7 +26,7 @@ import { ModerationLogService } from '@/core/ModerationLogService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
-import { randomString } from '../utils.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
process.env.NODE_ENV = 'test';
@@ -110,6 +111,9 @@ describe('AbuseReportNotificationService', () => {
{
provide: SystemWebhookService, useFactory: () => ({ enqueueSystemWebhook: jest.fn() }),
},
+ {
+ provide: UserEntityService, useFactory: () => ({ pack: (v: any) => v }),
+ },
{
provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }),
},
diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts
index bf8f3ab0e3..1e3605aafc 100644
--- a/packages/backend/test/unit/FetchInstanceMetadataService.ts
+++ b/packages/backend/test/unit/FetchInstanceMetadataService.ts
@@ -8,6 +8,7 @@ process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { Test } from '@nestjs/testing';
import { Redis } from 'ioredis';
+import type { TestingModule } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@@ -16,7 +17,6 @@ import { LoggerService } from '@/core/LoggerService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
-import type { TestingModule } from '@nestjs/testing';
function mockRedis() {
const hash = {} as any;
@@ -52,7 +52,7 @@ describe('FetchInstanceMetadataService', () => {
if (token === HttpRequestService) {
return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn() };
} else if (token === FederatedInstanceService) {
- return { fetch: jest.fn() };
+ return { fetchOrRegister: jest.fn() };
} else if (token === DI.redis) {
return mockRedis;
}
@@ -75,7 +75,7 @@ describe('FetchInstanceMetadataService', () => {
test('Lock and update', async () => {
redisClient.set = mockRedis();
const now = Date.now();
- federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
+ federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
@@ -83,14 +83,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
- expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
+ expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalled();
});
test('Lock and don\'t update', async () => {
redisClient.set = mockRedis();
const now = Date.now();
- federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
+ federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
@@ -98,14 +98,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
- expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
+ expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
});
test('Do nothing when lock not acquired', async () => {
redisClient.set = mockRedis();
const now = Date.now();
- federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
+ federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
@@ -114,14 +114,14 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(0);
- expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
+ expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
});
test('Do when lock not acquired but forced', async () => {
redisClient.set = mockRedis();
const now = Date.now();
- federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
+ federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
@@ -130,7 +130,7 @@ describe('FetchInstanceMetadataService', () => {
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true);
expect(tryLockSpy).toHaveBeenCalledTimes(0);
expect(unlockSpy).toHaveBeenCalledTimes(1);
- expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
+ expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalled();
});
});
diff --git a/packages/backend/test/unit/FlashService.ts b/packages/backend/test/unit/FlashService.ts
new file mode 100644
index 0000000000..12ffaf3421
--- /dev/null
+++ b/packages/backend/test/unit/FlashService.ts
@@ -0,0 +1,152 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Test, TestingModule } from '@nestjs/testing';
+import { FlashService } from '@/core/FlashService.js';
+import { IdService } from '@/core/IdService.js';
+import { FlashsRepository, MiFlash, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { GlobalModule } from '@/GlobalModule.js';
+
+describe('FlashService', () => {
+ let app: TestingModule;
+ let service: FlashService;
+
+ // --------------------------------------------------------------------------------------
+
+ let flashsRepository: FlashsRepository;
+ let usersRepository: UsersRepository;
+ let userProfilesRepository: UserProfilesRepository;
+ let idService: IdService;
+
+ // --------------------------------------------------------------------------------------
+
+ let root: MiUser;
+ let alice: MiUser;
+ let bob: MiUser;
+
+ // --------------------------------------------------------------------------------------
+
+ async function createFlash(data: Partial) {
+ return flashsRepository.insert({
+ id: idService.gen(),
+ updatedAt: new Date(),
+ userId: root.id,
+ title: 'title',
+ summary: 'summary',
+ script: 'script',
+ permissions: [],
+ likedCount: 0,
+ ...data,
+ }).then(x => flashsRepository.findOneByOrFail(x.identifiers[0]));
+ }
+
+ async function createUser(data: Partial = {}) {
+ const user = await usersRepository
+ .insert({
+ id: idService.gen(),
+ ...data,
+ })
+ .then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
+
+ await userProfilesRepository.insert({
+ userId: user.id,
+ });
+
+ return user;
+ }
+
+ // --------------------------------------------------------------------------------------
+
+ beforeEach(async () => {
+ app = await Test.createTestingModule({
+ imports: [
+ GlobalModule,
+ ],
+ providers: [
+ FlashService,
+ IdService,
+ ],
+ }).compile();
+
+ service = app.get(FlashService);
+
+ flashsRepository = app.get(DI.flashsRepository);
+ usersRepository = app.get(DI.usersRepository);
+ userProfilesRepository = app.get(DI.userProfilesRepository);
+ idService = app.get(IdService);
+
+ root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
+ alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false });
+ bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false });
+ });
+
+ afterEach(async () => {
+ await usersRepository.delete({});
+ await userProfilesRepository.delete({});
+ await flashsRepository.delete({});
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ // --------------------------------------------------------------------------------------
+
+ describe('featured', () => {
+ test('should return featured flashes', async () => {
+ const flash1 = await createFlash({ likedCount: 1 });
+ const flash2 = await createFlash({ likedCount: 2 });
+ const flash3 = await createFlash({ likedCount: 3 });
+
+ const result = await service.featured({
+ offset: 0,
+ limit: 10,
+ });
+
+ expect(result).toEqual([flash3, flash2, flash1]);
+ });
+
+ test('should return featured flashes public visibility only', async () => {
+ const flash1 = await createFlash({ likedCount: 1, visibility: 'public' });
+ const flash2 = await createFlash({ likedCount: 2, visibility: 'public' });
+ const flash3 = await createFlash({ likedCount: 3, visibility: 'private' });
+
+ const result = await service.featured({
+ offset: 0,
+ limit: 10,
+ });
+
+ expect(result).toEqual([flash2, flash1]);
+ });
+
+ test('should return featured flashes with offset', async () => {
+ const flash1 = await createFlash({ likedCount: 1 });
+ const flash2 = await createFlash({ likedCount: 2 });
+ const flash3 = await createFlash({ likedCount: 3 });
+
+ const result = await service.featured({
+ offset: 1,
+ limit: 10,
+ });
+
+ expect(result).toEqual([flash2, flash1]);
+ });
+
+ test('should return featured flashes with limit', async () => {
+ const flash1 = await createFlash({ likedCount: 1 });
+ const flash2 = await createFlash({ likedCount: 2 });
+ const flash3 = await createFlash({ likedCount: 3 });
+
+ const result = await service.featured({
+ offset: 0,
+ limit: 2,
+ });
+
+ expect(result).toEqual([flash3, flash2]);
+ });
+ });
+});
diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts
index b6cbe4c520..9c1b1008d6 100644
--- a/packages/backend/test/unit/RoleService.ts
+++ b/packages/backend/test/unit/RoleService.ts
@@ -10,9 +10,12 @@ import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
import * as lolex from '@sinonjs/fake-timers';
+import type { TestingModule } from '@nestjs/testing';
+import type { MockFunctionMetadata } from 'jest-mock';
import { GlobalModule } from '@/GlobalModule.js';
import { RoleService } from '@/core/RoleService.js';
import {
+ MiMeta,
MiRole,
MiRoleAssignment,
MiUser,
@@ -30,8 +33,6 @@ import { secureRndstr } from '@/misc/secure-rndstr.js';
import { NotificationService } from '@/core/NotificationService.js';
import { RoleCondFormulaValue } from '@/models/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import type { TestingModule } from '@nestjs/testing';
-import type { MockFunctionMetadata } from 'jest-mock';
const moduleMocker = new ModuleMocker(global);
@@ -41,7 +42,7 @@ describe('RoleService', () => {
let usersRepository: UsersRepository;
let rolesRepository: RolesRepository;
let roleAssignmentsRepository: RoleAssignmentsRepository;
- let metaService: jest.Mocked;
+ let meta: jest.Mocked;
let notificationService: jest.Mocked;
let clock: lolex.InstalledClock;
@@ -142,7 +143,7 @@ describe('RoleService', () => {
rolesRepository = app.get(DI.rolesRepository);
roleAssignmentsRepository = app.get(DI.roleAssignmentsRepository);
- metaService = app.get(MetaService) as jest.Mocked;
+ meta = app.get(DI.meta) as jest.Mocked;
notificationService = app.get(NotificationService) as jest.Mocked;
await roleService.onModuleInit();
@@ -164,11 +165,9 @@ describe('RoleService', () => {
describe('getUserPolicies', () => {
test('instance default policies', async () => {
const user = await createUser();
- metaService.fetch.mockResolvedValue({
- policies: {
- canManageCustomEmojis: false,
- },
- } as any);
+ meta.policies = {
+ canManageCustomEmojis: false,
+ };
const result = await roleService.getUserPolicies(user.id);
@@ -177,11 +176,9 @@ describe('RoleService', () => {
test('instance default policies 2', async () => {
const user = await createUser();
- metaService.fetch.mockResolvedValue({
- policies: {
- canManageCustomEmojis: true,
- },
- } as any);
+ meta.policies = {
+ canManageCustomEmojis: true,
+ };
const result = await roleService.getUserPolicies(user.id);
@@ -201,11 +198,9 @@ describe('RoleService', () => {
},
});
await roleService.assign(user.id, role.id);
- metaService.fetch.mockResolvedValue({
- policies: {
- canManageCustomEmojis: false,
- },
- } as any);
+ meta.policies = {
+ canManageCustomEmojis: false,
+ };
const result = await roleService.getUserPolicies(user.id);
@@ -236,11 +231,9 @@ describe('RoleService', () => {
});
await roleService.assign(user.id, role1.id);
await roleService.assign(user.id, role2.id);
- metaService.fetch.mockResolvedValue({
- policies: {
- driveCapacityMb: 50,
- },
- } as any);
+ meta.policies = {
+ driveCapacityMb: 50,
+ };
const result = await roleService.getUserPolicies(user.id);
@@ -260,11 +253,9 @@ describe('RoleService', () => {
},
});
await roleService.assign(user.id, role.id, new Date(Date.now() + (1000 * 60 * 60 * 24)));
- metaService.fetch.mockResolvedValue({
- policies: {
- canManageCustomEmojis: false,
- },
- } as any);
+ meta.policies = {
+ canManageCustomEmojis: false,
+ };
const result = await roleService.getUserPolicies(user.id);
expect(result.canManageCustomEmojis).toBe(true);
@@ -286,9 +277,9 @@ describe('RoleService', () => {
});
describe('getModeratorIds', () => {
- test('includeAdmins = false, excludeExpire = false', async () => {
- const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
- createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
+ test('includeAdmins = false, includeRoot = false, excludeExpire = false', async () => {
+ const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
+ createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -304,13 +295,17 @@ describe('RoleService', () => {
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]);
- const result = await roleService.getModeratorIds(false, false);
+ const result = await roleService.getModeratorIds({
+ includeAdmins: false,
+ includeRoot: false,
+ excludeExpire: false,
+ });
expect(result).toEqual([modeUser1.id, modeUser2.id]);
});
- test('includeAdmins = false, excludeExpire = true', async () => {
- const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
- createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
+ test('includeAdmins = false, includeRoot = false, excludeExpire = true', async () => {
+ const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
+ createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -326,13 +321,17 @@ describe('RoleService', () => {
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]);
- const result = await roleService.getModeratorIds(false, true);
+ const result = await roleService.getModeratorIds({
+ includeAdmins: false,
+ includeRoot: false,
+ excludeExpire: true,
+ });
expect(result).toEqual([modeUser1.id]);
});
- test('includeAdmins = true, excludeExpire = false', async () => {
- const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
- createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
+ test('includeAdmins = true, includeRoot = false, excludeExpire = false', async () => {
+ const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
+ createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -348,13 +347,17 @@ describe('RoleService', () => {
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]);
- const result = await roleService.getModeratorIds(true, false);
+ const result = await roleService.getModeratorIds({
+ includeAdmins: true,
+ includeRoot: false,
+ excludeExpire: false,
+ });
expect(result).toEqual([adminUser1.id, adminUser2.id, modeUser1.id, modeUser2.id]);
});
- test('includeAdmins = true, excludeExpire = true', async () => {
- const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
- createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
+ test('includeAdmins = true, includeRoot = false, excludeExpire = true', async () => {
+ const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
+ createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
]);
const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -370,9 +373,111 @@ describe('RoleService', () => {
assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
]);
- const result = await roleService.getModeratorIds(true, true);
+ const result = await roleService.getModeratorIds({
+ includeAdmins: true,
+ includeRoot: false,
+ excludeExpire: true,
+ });
expect(result).toEqual([adminUser1.id, modeUser1.id]);
});
+
+ test('includeAdmins = false, includeRoot = true, excludeExpire = false', async () => {
+ const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
+ createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ ]);
+
+ const role1 = await createRole({ name: 'admin', isAdministrator: true });
+ const role2 = await createRole({ name: 'moderator', isModerator: true });
+ const role3 = await createRole({ name: 'normal' });
+
+ await Promise.all([
+ assignRole({ userId: adminUser1.id, roleId: role1.id }),
+ assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }),
+ assignRole({ userId: modeUser1.id, roleId: role2.id }),
+ assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
+ assignRole({ userId: normalUser1.id, roleId: role3.id }),
+ assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
+ ]);
+
+ const result = await roleService.getModeratorIds({
+ includeAdmins: false,
+ includeRoot: true,
+ excludeExpire: false,
+ });
+ expect(result).toEqual([modeUser1.id, modeUser2.id, rootUser.id]);
+ });
+
+ test('root has moderator role', async () => {
+ const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
+ createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ ]);
+
+ const role1 = await createRole({ name: 'admin', isAdministrator: true });
+ const role2 = await createRole({ name: 'moderator', isModerator: true });
+ const role3 = await createRole({ name: 'normal' });
+
+ await Promise.all([
+ assignRole({ userId: adminUser1.id, roleId: role1.id }),
+ assignRole({ userId: modeUser1.id, roleId: role2.id }),
+ assignRole({ userId: rootUser.id, roleId: role2.id }),
+ assignRole({ userId: normalUser1.id, roleId: role3.id }),
+ ]);
+
+ const result = await roleService.getModeratorIds({
+ includeAdmins: false,
+ includeRoot: true,
+ excludeExpire: false,
+ });
+ expect(result).toEqual([modeUser1.id, rootUser.id]);
+ });
+
+ test('root has administrator role', async () => {
+ const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
+ createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ ]);
+
+ const role1 = await createRole({ name: 'admin', isAdministrator: true });
+ const role2 = await createRole({ name: 'moderator', isModerator: true });
+ const role3 = await createRole({ name: 'normal' });
+
+ await Promise.all([
+ assignRole({ userId: adminUser1.id, roleId: role1.id }),
+ assignRole({ userId: rootUser.id, roleId: role1.id }),
+ assignRole({ userId: modeUser1.id, roleId: role2.id }),
+ assignRole({ userId: normalUser1.id, roleId: role3.id }),
+ ]);
+
+ const result = await roleService.getModeratorIds({
+ includeAdmins: true,
+ includeRoot: true,
+ excludeExpire: false,
+ });
+ expect(result).toEqual([adminUser1.id, modeUser1.id, rootUser.id]);
+ });
+
+ test('root has moderator role(expire)', async () => {
+ const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
+ createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+ ]);
+
+ const role1 = await createRole({ name: 'admin', isAdministrator: true });
+ const role2 = await createRole({ name: 'moderator', isModerator: true });
+ const role3 = await createRole({ name: 'normal' });
+
+ await Promise.all([
+ assignRole({ userId: adminUser1.id, roleId: role1.id }),
+ assignRole({ userId: modeUser1.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
+ assignRole({ userId: rootUser.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
+ assignRole({ userId: normalUser1.id, roleId: role3.id }),
+ ]);
+
+ const result = await roleService.getModeratorIds({
+ includeAdmins: false,
+ includeRoot: true,
+ excludeExpire: true,
+ });
+ expect(result).toEqual([rootUser.id]);
+ });
});
describe('conditional role', () => {
diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts
new file mode 100644
index 0000000000..bae2b88c60
--- /dev/null
+++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts
@@ -0,0 +1,182 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { IncomingHttpHeaders } from 'node:http';
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals';
+import { Test, TestingModule } from '@nestjs/testing';
+import { FastifyReply, FastifyRequest } from 'fastify';
+import { AuthenticationResponseJSON } from '@simplewebauthn/types';
+import { HttpHeader } from 'fastify/types/utils.js';
+import { MockFunctionMetadata, ModuleMocker } from 'jest-mock';
+import { MiUser } from '@/models/User.js';
+import { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { DI } from '@/di-symbols.js';
+import { CoreModule } from '@/core/CoreModule.js';
+import { SigninWithPasskeyApiService } from '@/server/api/SigninWithPasskeyApiService.js';
+import { RateLimiterService } from '@/server/api/RateLimiterService.js';
+import { WebAuthnService } from '@/core/WebAuthnService.js';
+import { SigninService } from '@/server/api/SigninService.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
+
+const moduleMocker = new ModuleMocker(global);
+
+class FakeLimiter {
+ public async limit() {
+ return;
+ }
+}
+
+class FakeSigninService {
+ public signin(..._args: any): any {
+ return true;
+ }
+}
+
+class DummyFastifyReply {
+ public statusCode: number;
+ code(num: number): void {
+ this.statusCode = num;
+ }
+ header(_key: HttpHeader, _value: any): void {
+ }
+}
+class DummyFastifyRequest {
+ public ip: string;
+ public body: {credential: any, context: string};
+ public headers: IncomingHttpHeaders = { 'accept': 'application/json' };
+ constructor(body?: any) {
+ this.ip = '0.0.0.0';
+ this.body = body;
+ }
+}
+
+type ApiFastifyRequestType = FastifyRequest<{
+ Body: {
+ credential?: AuthenticationResponseJSON;
+ context?: string;
+ };
+}>;
+
+describe('SigninWithPasskeyApiService', () => {
+ let app: TestingModule;
+ let passkeyApiService: SigninWithPasskeyApiService;
+ let usersRepository: UsersRepository;
+ let userProfilesRepository: UserProfilesRepository;
+ let webAuthnService: WebAuthnService;
+ let idService: IdService;
+ let FakeWebauthnVerify: ()=>Promise;
+
+ async function createUser(data: Partial = {}) {
+ const user = await usersRepository
+ .save({
+ ...data,
+ });
+ return user;
+ }
+
+ async function createUserProfile(data: Partial = {}) {
+ const userProfile = await userProfilesRepository
+ .save({ ...data },
+ );
+ return userProfile;
+ }
+
+ beforeAll(async () => {
+ app = await Test.createTestingModule({
+ imports: [GlobalModule, CoreModule],
+ providers: [
+ SigninWithPasskeyApiService,
+ { provide: RateLimiterService, useClass: FakeLimiter },
+ { provide: SigninService, useClass: FakeSigninService },
+ ],
+ }).useMocker((token) => {
+ if (typeof token === 'function') {
+ const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata;
+ const Mock = moduleMocker.generateFromMetadata(mockMetadata);
+ return new Mock();
+ }
+ }).compile();
+ passkeyApiService = app.get(SigninWithPasskeyApiService);
+ usersRepository = app.get(DI.usersRepository);
+ userProfilesRepository = app.get(DI.userProfilesRepository);
+ webAuthnService = app.get(WebAuthnService);
+ idService = app.get(IdService);
+ });
+
+ beforeEach(async () => {
+ const uid = idService.gen();
+ FakeWebauthnVerify = async () => {
+ return uid;
+ };
+ jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify);
+
+ const dummyUser = {
+ id: uid, username: uid, usernameLower: uid.toLocaleLowerCase(), uri: null, host: null,
+ };
+ const dummyProfile = {
+ userId: uid,
+ password: 'qwerty',
+ usePasswordLessLogin: true,
+ };
+ await createUser(dummyUser);
+ await createUserProfile(dummyProfile);
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ describe('Get Passkey Options', () => {
+ it('Should return passkey Auth Options', async () => {
+ const req = new DummyFastifyRequest({}) as ApiFastifyRequestType;
+ const res = new DummyFastifyReply() as unknown as FastifyReply;
+ const res_body = await passkeyApiService.signin(req, res);
+ expect(res.statusCode).toBe(200);
+ expect((res_body as any).option).toBeDefined();
+ expect(typeof (res_body as any).context).toBe('string');
+ });
+ });
+ describe('Try Passkey Auth', () => {
+ it('Should Success', async () => {
+ const req = new DummyFastifyRequest({ context: 'auth-context', credential: { dummy: [] } }) as ApiFastifyRequestType;
+ const res = new DummyFastifyReply() as FastifyReply;
+ const res_body = await passkeyApiService.signin(req, res);
+ expect((res_body as any).signinResponse).toBeDefined();
+ });
+
+ it('Should return 400 Without Auth Context', async () => {
+ const req = new DummyFastifyRequest({ credential: { dummy: [] } }) as ApiFastifyRequestType;
+ const res = new DummyFastifyReply() as FastifyReply;
+ const res_body = await passkeyApiService.signin(req, res);
+ expect(res.statusCode).toBe(400);
+ expect((res_body as any).error?.id).toStrictEqual('1658cc2e-4495-461f-aee4-d403cdf073c1');
+ });
+
+ it('Should return 403 When Challenge Verify fail', async () => {
+ const req = new DummyFastifyRequest({ context: 'misskey-1234', credential: { dummy: [] } }) as ApiFastifyRequestType;
+ const res = new DummyFastifyReply() as FastifyReply;
+ jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication')
+ .mockImplementation(async () => {
+ throw new IdentifiableError('THIS_ERROR_CODE_SHOULD_BE_FORWARDED');
+ });
+ const res_body = await passkeyApiService.signin(req, res);
+ expect(res.statusCode).toBe(403);
+ expect((res_body as any).error?.id).toStrictEqual('THIS_ERROR_CODE_SHOULD_BE_FORWARDED');
+ });
+
+ it('Should return 403 When The user not Enabled Passwordless login', async () => {
+ const req = new DummyFastifyRequest({ context: 'misskey-1234', credential: { dummy: [] } }) as ApiFastifyRequestType;
+ const res = new DummyFastifyReply() as FastifyReply;
+ const userId = await FakeWebauthnVerify();
+ const data = { userId: userId, usePasswordLessLogin: false };
+ await userProfilesRepository.update({ userId: userId }, data);
+ const res_body = await passkeyApiService.signin(req, res);
+ expect(res.statusCode).toBe(403);
+ expect((res_body as any).error?.id).toStrictEqual('2d84773e-f7b7-4d0b-8f72-bb69b584c912');
+ });
+ });
+});
diff --git a/packages/backend/test/unit/SystemWebhookService.ts b/packages/backend/test/unit/SystemWebhookService.ts
index 790cd1490e..5401dd74d8 100644
--- a/packages/backend/test/unit/SystemWebhookService.ts
+++ b/packages/backend/test/unit/SystemWebhookService.ts
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
@@ -6,6 +7,7 @@
import { setTimeout } from 'node:timers/promises';
import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
+import { randomString } from '../utils.js';
import { MiUser } from '@/models/User.js';
import { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
import { SystemWebhooksRepository, UsersRepository } from '@/models/_.js';
@@ -17,7 +19,6 @@ import { DI } from '@/di-symbols.js';
import { QueueService } from '@/core/QueueService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
-import { randomString } from '../utils.js';
describe('SystemWebhookService', () => {
let app: TestingModule;
@@ -313,7 +314,7 @@ describe('SystemWebhookService', () => {
isActive: true,
on: ['abuseReport'],
});
- await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' });
+ await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any);
expect(queueService.systemWebhookDeliver).toHaveBeenCalled();
});
@@ -323,7 +324,7 @@ describe('SystemWebhookService', () => {
isActive: false,
on: ['abuseReport'],
});
- await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' });
+ await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any);
expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled();
});
@@ -337,8 +338,8 @@ describe('SystemWebhookService', () => {
isActive: true,
on: ['abuseReportResolved'],
});
- await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' });
- await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' });
+ await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' } as any);
+ await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' } as any);
expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled();
});
diff --git a/packages/backend/test/unit/UserWebhookService.ts b/packages/backend/test/unit/UserWebhookService.ts
new file mode 100644
index 0000000000..0e88835a02
--- /dev/null
+++ b/packages/backend/test/unit/UserWebhookService.ts
@@ -0,0 +1,245 @@
+
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals';
+import { Test, TestingModule } from '@nestjs/testing';
+import { randomString } from '../utils.js';
+import { MiUser } from '@/models/User.js';
+import { MiWebhook, UsersRepository, WebhooksRepository } from '@/models/_.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { DI } from '@/di-symbols.js';
+import { QueueService } from '@/core/QueueService.js';
+import { LoggerService } from '@/core/LoggerService.js';
+import { UserWebhookService } from '@/core/UserWebhookService.js';
+
+describe('UserWebhookService', () => {
+ let app: TestingModule;
+ let service: UserWebhookService;
+
+ // --------------------------------------------------------------------------------------
+
+ let usersRepository: UsersRepository;
+ let userWebhooksRepository: WebhooksRepository;
+ let idService: IdService;
+ let queueService: jest.Mocked;
+
+ // --------------------------------------------------------------------------------------
+
+ let root: MiUser;
+
+ // --------------------------------------------------------------------------------------
+
+ async function createUser(data: Partial = {}) {
+ return await usersRepository
+ .insert({
+ id: idService.gen(),
+ ...data,
+ })
+ .then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
+ }
+
+ async function createWebhook(data: Partial = {}) {
+ return userWebhooksRepository
+ .insert({
+ id: idService.gen(),
+ name: randomString(),
+ on: ['mention'],
+ url: 'https://example.com',
+ secret: randomString(),
+ userId: root.id,
+ ...data,
+ })
+ .then(x => userWebhooksRepository.findOneByOrFail(x.identifiers[0]));
+ }
+
+ // --------------------------------------------------------------------------------------
+
+ async function beforeAllImpl() {
+ app = await Test
+ .createTestingModule({
+ imports: [
+ GlobalModule,
+ ],
+ providers: [
+ UserWebhookService,
+ IdService,
+ LoggerService,
+ GlobalEventService,
+ {
+ provide: QueueService, useFactory: () => ({ systemWebhookDeliver: jest.fn() }),
+ },
+ ],
+ })
+ .compile();
+
+ usersRepository = app.get(DI.usersRepository);
+ userWebhooksRepository = app.get(DI.webhooksRepository);
+
+ service = app.get(UserWebhookService);
+ idService = app.get(IdService);
+ queueService = app.get(QueueService) as jest.Mocked;
+
+ app.enableShutdownHooks();
+ }
+
+ async function afterAllImpl() {
+ await app.close();
+ }
+
+ async function beforeEachImpl() {
+ root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' });
+ }
+
+ async function afterEachImpl() {
+ await usersRepository.delete({});
+ await userWebhooksRepository.delete({});
+ }
+
+ // --------------------------------------------------------------------------------------
+
+ describe('アプリを毎回作り直す必要のないグループ', () => {
+ beforeAll(beforeAllImpl);
+ afterAll(afterAllImpl);
+ beforeEach(beforeEachImpl);
+ afterEach(afterEachImpl);
+
+ describe('fetchSystemWebhooks', () => {
+ test('フィルタなし', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks();
+ expect(fetchedWebhooks).toEqual([webhook1, webhook2, webhook3, webhook4]);
+ });
+
+ test('activeのみ', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks({ isActive: true });
+ expect(fetchedWebhooks).toEqual([webhook1, webhook3]);
+ });
+
+ test('特定のイベントのみ', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks({ on: ['mention'] });
+ expect(fetchedWebhooks).toEqual([webhook1, webhook2]);
+ });
+
+ test('activeな特定のイベントのみ', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks({ on: ['mention'], isActive: true });
+ expect(fetchedWebhooks).toEqual([webhook1]);
+ });
+
+ test('ID指定', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks({ ids: [webhook1.id, webhook4.id] });
+ expect(fetchedWebhooks).toEqual([webhook1, webhook4]);
+ });
+
+ test('ID指定(他条件とANDになるか見たい)', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks({ ids: [webhook1.id, webhook4.id], isActive: false });
+ expect(fetchedWebhooks).toEqual([webhook4]);
+ });
+ });
+ });
+});
diff --git a/packages/backend/test/unit/WebhookTestService.ts b/packages/backend/test/unit/WebhookTestService.ts
new file mode 100644
index 0000000000..be84ae9b84
--- /dev/null
+++ b/packages/backend/test/unit/WebhookTestService.ts
@@ -0,0 +1,225 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Test, TestingModule } from '@nestjs/testing';
+import { beforeAll, describe, jest } from '@jest/globals';
+import { WebhookTestService } from '@/core/WebhookTestService.js';
+import { UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js';
+import { SystemWebhookService } from '@/core/SystemWebhookService.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { MiSystemWebhook, MiUser, MiWebhook, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import { IdService } from '@/core/IdService.js';
+import { DI } from '@/di-symbols.js';
+import { QueueService } from '@/core/QueueService.js';
+
+describe('WebhookTestService', () => {
+ let app: TestingModule;
+ let service: WebhookTestService;
+
+ // --------------------------------------------------------------------------------------
+
+ let usersRepository: UsersRepository;
+ let userProfilesRepository: UserProfilesRepository;
+ let queueService: jest.Mocked;
+ let userWebhookService: jest.Mocked;
+ let systemWebhookService: jest.Mocked;
+ let idService: IdService;
+
+ let root: MiUser;
+ let alice: MiUser;
+
+ async function createUser(data: Partial = {}) {
+ const user = await usersRepository
+ .insert({
+ id: idService.gen(),
+ ...data,
+ })
+ .then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
+
+ await userProfilesRepository.insert({
+ userId: user.id,
+ });
+
+ return user;
+ }
+
+ // --------------------------------------------------------------------------------------
+
+ beforeAll(async () => {
+ app = await Test.createTestingModule({
+ imports: [
+ GlobalModule,
+ ],
+ providers: [
+ WebhookTestService,
+ IdService,
+ {
+ provide: QueueService, useFactory: () => ({
+ systemWebhookDeliver: jest.fn(),
+ userWebhookDeliver: jest.fn(),
+ }),
+ },
+ {
+ provide: UserWebhookService, useFactory: () => ({
+ fetchWebhooks: jest.fn(),
+ }),
+ },
+ {
+ provide: SystemWebhookService, useFactory: () => ({
+ fetchSystemWebhooks: jest.fn(),
+ }),
+ },
+ ],
+ }).compile();
+
+ usersRepository = app.get(DI.usersRepository);
+ userProfilesRepository = app.get(DI.userProfilesRepository);
+
+ service = app.get(WebhookTestService);
+ idService = app.get(IdService);
+ queueService = app.get(QueueService) as jest.Mocked;
+ userWebhookService = app.get(UserWebhookService) as jest.Mocked;
+ systemWebhookService = app.get(SystemWebhookService) as jest.Mocked;
+
+ app.enableShutdownHooks();
+ });
+
+ beforeEach(async () => {
+ root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
+ alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false });
+
+ userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([
+ { id: 'dummy-webhook', active: true, userId: alice.id } as MiWebhook,
+ ]));
+ systemWebhookService.fetchSystemWebhooks.mockReturnValue(Promise.resolve([
+ { id: 'dummy-webhook', isActive: true } as MiSystemWebhook,
+ ]));
+ });
+
+ afterEach(async () => {
+ queueService.systemWebhookDeliver.mockClear();
+ queueService.userWebhookDeliver.mockClear();
+ userWebhookService.fetchWebhooks.mockClear();
+ systemWebhookService.fetchSystemWebhooks.mockClear();
+
+ await usersRepository.delete({});
+ await userProfilesRepository.delete({});
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ // --------------------------------------------------------------------------------------
+
+ describe('testUserWebhook', () => {
+ test('note', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'note' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('note');
+ expect((calls[2] as UserWebhookPayload<'note'>).note.id).toBe('dummy-note-1');
+ });
+
+ test('reply', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'reply' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('reply');
+ expect((calls[2] as UserWebhookPayload<'reply'>).note.id).toBe('dummy-reply-1');
+ });
+
+ test('renote', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'renote' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('renote');
+ expect((calls[2] as UserWebhookPayload<'renote'>).note.id).toBe('dummy-renote-1');
+ });
+
+ test('mention', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'mention' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('mention');
+ expect((calls[2] as UserWebhookPayload<'mention'>).note.id).toBe('dummy-mention-1');
+ });
+
+ test('follow', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'follow' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('follow');
+ expect((calls[2] as UserWebhookPayload<'follow'>).user.id).toBe('dummy-user-1');
+ });
+
+ test('followed', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'followed' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('followed');
+ expect((calls[2] as UserWebhookPayload<'followed'>).user.id).toBe('dummy-user-2');
+ });
+
+ test('unfollow', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'unfollow' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('unfollow');
+ expect((calls[2] as UserWebhookPayload<'unfollow'>).user.id).toBe('dummy-user-3');
+ });
+
+ describe('NoSuchWebhookError', () => {
+ test('user not match', async () => {
+ userWebhookService.fetchWebhooks.mockClear();
+ userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([
+ { id: 'dummy-webhook', active: true } as MiWebhook,
+ ]));
+
+ await expect(service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'note' }, root))
+ .rejects.toThrow(WebhookTestService.NoSuchWebhookError);
+ });
+ });
+ });
+
+ describe('testSystemWebhook', () => {
+ test('abuseReport', async () => {
+ await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'abuseReport' });
+
+ const calls = queueService.systemWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('abuseReport');
+ expect((calls[2] as any).id).toBe('dummy-abuse-report1');
+ expect((calls[2] as any).resolved).toBe(false);
+ });
+
+ test('abuseReportResolved', async () => {
+ await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'abuseReportResolved' });
+
+ const calls = queueService.systemWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('abuseReportResolved');
+ expect((calls[2] as any).id).toBe('dummy-abuse-report1');
+ expect((calls[2] as any).resolved).toBe(true);
+ });
+
+ test('userCreated', async () => {
+ await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'userCreated' });
+
+ const calls = queueService.systemWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('userCreated');
+ expect((calls[2] as any).id).toBe('dummy-user-1');
+ });
+ });
+});
diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts
index 763ce2b336..2fc08aec91 100644
--- a/packages/backend/test/unit/activitypub.ts
+++ b/packages/backend/test/unit/activitypub.ts
@@ -24,7 +24,6 @@ import { MiMeta, MiNote, UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DownloadService } from '@/core/DownloadService.js';
-import { MetaService } from '@/core/MetaService.js';
import type { MiRemoteUser } from '@/models/User.js';
import { genAidx } from '@/misc/id/aidx.js';
import { MockResolver } from '../misc/mock-resolver.js';
@@ -107,7 +106,14 @@ describe('ActivityPub', () => {
sensitiveWords: [] as string[],
prohibitedWords: [] as string[],
} as MiMeta;
- let meta = metaInitial;
+ const meta = { ...metaInitial };
+
+ function updateMeta(newMeta: Partial): void {
+ for (const key in meta) {
+ delete (meta as any)[key];
+ }
+ Object.assign(meta, newMeta);
+ }
beforeAll(async () => {
const app = await Test.createTestingModule({
@@ -120,11 +126,8 @@ describe('ActivityPub', () => {
};
},
})
- .overrideProvider(MetaService).useValue({
- async fetch(): Promise {
- return meta;
- },
- }).compile();
+ .overrideProvider(DI.meta).useFactory({ factory: () => meta })
+ .compile();
await app.init();
app.enableShutdownHooks();
@@ -367,7 +370,7 @@ describe('ActivityPub', () => {
});
test('cacheRemoteFiles=false disables caching', async () => {
- meta = { ...metaInitial, cacheRemoteFiles: false };
+ updateMeta({ ...metaInitial, cacheRemoteFiles: false });
const imageObject: IApDocument = {
type: 'Document',
@@ -396,7 +399,7 @@ describe('ActivityPub', () => {
});
test('cacheRemoteSensitiveFiles=false only affects sensitive files', async () => {
- meta = { ...metaInitial, cacheRemoteSensitiveFiles: false };
+ updateMeta({ ...metaInitial, cacheRemoteSensitiveFiles: false });
const imageObject: IApDocument = {
type: 'Document',
diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts
index ee16d421c4..e4f42809f8 100644
--- a/packages/backend/test/unit/entities/UserEntityService.ts
+++ b/packages/backend/test/unit/entities/UserEntityService.ts
@@ -4,10 +4,10 @@
*/
import { Test, TestingModule } from '@nestjs/testing';
+import type { MiUser } from '@/models/User.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
-import type { MiUser } from '@/models/User.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { genAidx } from '@/misc/id/aidx.js';
import {
@@ -49,6 +49,7 @@ import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { ReactionService } from '@/core/ReactionService.js';
import { NotificationService } from '@/core/NotificationService.js';
+import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
process.env.NODE_ENV = 'test';
@@ -169,6 +170,7 @@ describe('UserEntityService', () => {
ApLoggerService,
AccountMoveService,
ReactionService,
+ ReactionsBufferingService,
NotificationService,
];
diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
new file mode 100644
index 0000000000..1506283a3c
--- /dev/null
+++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
@@ -0,0 +1,379 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { jest } from '@jest/globals';
+import { Test, TestingModule } from '@nestjs/testing';
+import * as lolex from '@sinonjs/fake-timers';
+import { addHours, addSeconds, subDays, subHours, subSeconds } from 'date-fns';
+import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
+import { MiSystemWebhook, MiUser, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import { IdService } from '@/core/IdService.js';
+import { RoleService } from '@/core/RoleService.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { MetaService } from '@/core/MetaService.js';
+import { DI } from '@/di-symbols.js';
+import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
+import { EmailService } from '@/core/EmailService.js';
+import { SystemWebhookService } from '@/core/SystemWebhookService.js';
+import { AnnouncementService } from '@/core/AnnouncementService.js';
+
+const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0));
+
+describe('CheckModeratorsActivityProcessorService', () => {
+ let app: TestingModule;
+ let clock: lolex.InstalledClock;
+ let service: CheckModeratorsActivityProcessorService;
+
+ // --------------------------------------------------------------------------------------
+
+ let usersRepository: UsersRepository;
+ let userProfilesRepository: UserProfilesRepository;
+ let idService: IdService;
+ let roleService: jest.Mocked;
+ let announcementService: jest.Mocked;
+ let emailService: jest.Mocked;
+ let systemWebhookService: jest.Mocked;
+
+ let systemWebhook1: MiSystemWebhook;
+ let systemWebhook2: MiSystemWebhook;
+ let systemWebhook3: MiSystemWebhook;
+
+ // --------------------------------------------------------------------------------------
+
+ async function createUser(data: Partial = {}, profile: Partial = {}): Promise {
+ const id = idService.gen();
+ const user = await usersRepository
+ .insert({
+ id: id,
+ username: `user_${id}`,
+ usernameLower: `user_${id}`.toLowerCase(),
+ ...data,
+ })
+ .then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
+
+ await userProfilesRepository.insert({
+ userId: user.id,
+ ...profile,
+ });
+
+ return user;
+ }
+
+ function crateSystemWebhook(data: Partial = {}): MiSystemWebhook {
+ return {
+ id: idService.gen(),
+ isActive: true,
+ updatedAt: new Date(),
+ latestSentAt: null,
+ latestStatus: null,
+ name: 'test',
+ url: 'https://example.com',
+ secret: 'test',
+ on: [],
+ ...data,
+ };
+ }
+
+ function mockModeratorRole(users: MiUser[]) {
+ roleService.getModerators.mockReset();
+ roleService.getModerators.mockResolvedValue(users);
+ }
+
+ // --------------------------------------------------------------------------------------
+
+ beforeAll(async () => {
+ app = await Test
+ .createTestingModule({
+ imports: [
+ GlobalModule,
+ ],
+ providers: [
+ CheckModeratorsActivityProcessorService,
+ IdService,
+ {
+ provide: RoleService, useFactory: () => ({ getModerators: jest.fn() }),
+ },
+ {
+ provide: MetaService, useFactory: () => ({ fetch: jest.fn() }),
+ },
+ {
+ provide: AnnouncementService, useFactory: () => ({ create: jest.fn() }),
+ },
+ {
+ provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }),
+ },
+ {
+ provide: SystemWebhookService, useFactory: () => ({
+ fetchActiveSystemWebhooks: jest.fn(),
+ enqueueSystemWebhook: jest.fn(),
+ }),
+ },
+ {
+ provide: QueueLoggerService, useFactory: () => ({
+ logger: ({
+ createSubLogger: () => ({
+ info: jest.fn(),
+ warn: jest.fn(),
+ succ: jest.fn(),
+ }),
+ }),
+ }),
+ },
+ ],
+ })
+ .compile();
+
+ usersRepository = app.get(DI.usersRepository);
+ userProfilesRepository = app.get(DI.userProfilesRepository);
+
+ service = app.get(CheckModeratorsActivityProcessorService);
+ idService = app.get(IdService);
+ roleService = app.get(RoleService) as jest.Mocked