summaryrefslogtreecommitdiff
path: root/packages/backend
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend')
-rw-r--r--packages/backend/src/core/AbuseReportNotificationService.ts10
-rw-r--r--packages/backend/src/core/QueueService.ts7
-rw-r--r--packages/backend/src/core/RoleService.ts75
-rw-r--r--packages/backend/src/queue/QueueProcessorModule.ts3
-rw-r--r--packages/backend/src/queue/QueueProcessorService.ts3
-rw-r--r--packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts127
-rw-r--r--packages/backend/src/server/api/endpoints/admin/show-users.ts4
-rw-r--r--packages/backend/test/unit/RoleService.ts150
-rw-r--r--packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts235
9 files changed, 570 insertions, 44 deletions
diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts
index fb7c7bd2c3..7d030f2f16 100644
--- a/packages/backend/src/core/AbuseReportNotificationService.ts
+++ b/packages/backend/src/core/AbuseReportNotificationService.ts
@@ -61,7 +61,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
return;
}
- const moderatorIds = await this.roleService.getModeratorIds(true, true);
+ const moderatorIds = await this.roleService.getModeratorIds({
+ includeAdmins: true,
+ excludeExpire: true,
+ });
for (const moderatorId of moderatorIds) {
for (const abuseReport of abuseReports) {
@@ -370,7 +373,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
}
// モデレータ権限の有無で通知先設定を振り分ける
- const authorizedUserIds = await this.roleService.getModeratorIds(true, true);
+ const authorizedUserIds = await this.roleService.getModeratorIds({
+ includeAdmins: true,
+ excludeExpire: true,
+ });
const authorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
const unauthorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
for (const recipient of userRecipients) {
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index f35e456556..37028026cc 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -93,6 +93,13 @@ export class QueueService {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: true,
});
+
+ this.systemQueue.add('checkModeratorsActivity', {
+ }, {
+ // 毎時30分に起動
+ repeat: { pattern: '30 * * * *' },
+ removeOnComplete: true,
+ });
}
@bindThis
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 583eea1a34..5af6b05942 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -101,6 +101,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
@Injectable()
export class RoleService implements OnApplicationShutdown, OnModuleInit {
+ private rootUserIdCache: MemorySingleCache<MiUser['id']>;
private rolesCache: MemorySingleCache<MiRole[]>;
private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>;
private notificationService: NotificationService;
@@ -136,6 +137,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private moderationLogService: ModerationLogService,
private fanoutTimelineService: FanoutTimelineService,
) {
+ this.rootUserIdCache = new MemorySingleCache<MiUser['id']>(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
@@ -416,49 +418,78 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
- public async isExplorable(role: { id: MiRole['id']} | null): Promise<boolean> {
+ public async isExplorable(role: { id: MiRole['id'] } | null): Promise<boolean> {
if (role == null) return false;
const check = await this.rolesRepository.findOneBy({ id: role.id });
if (check == null) return false;
return check.isExplorable;
}
+ /**
+ * モデレーター権限のロールが割り当てられているユーザID一覧を取得する.
+ *
+ * @param opts.includeAdmins 管理者権限も含めるか(デフォルト: true)
+ * @param opts.includeRoot rootユーザも含めるか(デフォルト: false)
+ * @param opts.excludeExpire 期限切れのロールを除外するか(デフォルト: false)
+ */
@bindThis
- public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise<MiUser['id'][]> {
+ public async getModeratorIds(opts?: {
+ includeAdmins?: boolean,
+ includeRoot?: boolean,
+ excludeExpire?: boolean,
+ }): Promise<MiUser['id'][]> {
+ const includeAdmins = opts?.includeAdmins ?? true;
+ const includeRoot = opts?.includeRoot ?? false;
+ const excludeExpire = opts?.excludeExpire ?? false;
+
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
const moderatorRoles = includeAdmins
? roles.filter(r => r.isModerator || r.isAdministrator)
: roles.filter(r => r.isModerator);
- // TODO: isRootなアカウントも含める
const assigns = moderatorRoles.length > 0
? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) })
: [];
+ // Setを経由して重複を除去(ユーザIDは重複する可能性があるので)
const now = Date.now();
- const result = [
- // Setを経由して重複を除去(ユーザIDは重複する可能性があるので)
- ...new Set(
- assigns
- .filter(it =>
- (excludeExpire)
- ? (it.expiresAt == null || it.expiresAt.getTime() > now)
- : true,
- )
- .map(a => a.userId),
- ),
- ];
+ const resultSet = new Set(
+ assigns
+ .filter(it =>
+ (excludeExpire)
+ ? (it.expiresAt == null || it.expiresAt.getTime() > now)
+ : true,
+ )
+ .map(a => a.userId),
+ );
+
+ if (includeRoot) {
+ const rootUserId = await this.rootUserIdCache.fetch(async () => {
+ const it = await this.usersRepository.createQueryBuilder('users')
+ .select('id')
+ .where({ isRoot: true })
+ .getRawOne<{ id: string }>();
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return it!.id;
+ });
+ resultSet.add(rootUserId);
+ }
- return result.sort((x, y) => x.localeCompare(y));
+ return [...resultSet].sort((x, y) => x.localeCompare(y));
}
@bindThis
- public async getModerators(includeAdmins = true): Promise<MiUser[]> {
- const ids = await this.getModeratorIds(includeAdmins);
- const users = ids.length > 0 ? await this.usersRepository.findBy({
- id: In(ids),
- }) : [];
- return users;
+ public async getModerators(opts?: {
+ includeAdmins?: boolean,
+ includeRoot?: boolean,
+ excludeExpire?: boolean,
+ }): Promise<MiUser[]> {
+ const ids = await this.getModeratorIds(opts);
+ return ids.length > 0
+ ? await this.usersRepository.findBy({
+ id: In(ids),
+ })
+ : [];
}
@bindThis
diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts
index 0027b5ef3d..9044285bf6 100644
--- a/packages/backend/src/queue/QueueProcessorModule.ts
+++ b/packages/backend/src/queue/QueueProcessorModule.ts
@@ -6,6 +6,7 @@
import { Module } from '@nestjs/common';
import { CoreModule } from '@/core/CoreModule.js';
import { GlobalModule } from '@/GlobalModule.js';
+import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js';
import { QueueProcessorService } from './QueueProcessorService.js';
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
@@ -80,6 +81,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
DeliverProcessorService,
InboxProcessorService,
AggregateRetentionProcessorService,
+ CheckExpiredMutingsProcessorService,
+ CheckModeratorsActivityProcessorService,
QueueProcessorService,
],
exports: [
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index e9e1c45224..85e148e900 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
+import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@@ -120,6 +121,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
+ private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
private cleanProcessorService: CleanProcessorService,
) {
this.logger = this.queueLoggerService.logger;
@@ -150,6 +152,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process();
+ case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process();
case 'clean': return this.cleanProcessorService.process();
default: throw new Error(`unrecognized job type ${job.name} for system`);
}
diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
new file mode 100644
index 0000000000..f2677f8e5c
--- /dev/null
+++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
@@ -0,0 +1,127 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import type Logger from '@/logger.js';
+import { bindThis } from '@/decorators.js';
+import { MetaService } from '@/core/MetaService.js';
+import { RoleService } from '@/core/RoleService.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+
+// モデレーターが不在と判断する日付の閾値
+const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
+const ONE_DAY_MILLI_SEC = 1000 * 60 * 60 * 24;
+
+@Injectable()
+export class CheckModeratorsActivityProcessorService {
+ private logger: Logger;
+
+ constructor(
+ private metaService: MetaService,
+ private roleService: RoleService,
+ private queueLoggerService: QueueLoggerService,
+ ) {
+ this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
+ }
+
+ @bindThis
+ public async process(): Promise<void> {
+ this.logger.info('start.');
+
+ const meta = await this.metaService.fetch(false);
+ if (!meta.disableRegistration) {
+ await this.processImpl();
+ } else {
+ this.logger.info('is already invitation only.');
+ }
+
+ this.logger.succ('finish.');
+ }
+
+ @bindThis
+ private async processImpl() {
+ const { isModeratorsInactive, inactivityLimitCountdown } = await this.evaluateModeratorsInactiveDays();
+ if (isModeratorsInactive) {
+ this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
+ await this.changeToInvitationOnly();
+
+ // TODO: モデレータに通知メール+Misskey通知
+ // TODO: SystemWebhook通知
+ } else {
+ if (inactivityLimitCountdown <= 2) {
+ this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${inactivityLimitCountdown} days, it will switch to invitation only.`);
+
+ // TODO: 警告メール
+ }
+ }
+ }
+
+ /**
+ * モデレーターが不在であるかどうかを確認する。trueの場合はモデレーターが不在である。
+ * isModerator, isAdministrator, isRootのいずれかがtrueのユーザを対象に、
+ * {@link MiUser.lastActiveDate}の値が実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前よりも古いユーザがいるかどうかを確認する。
+ * {@link MiUser.lastActiveDate}がnullの場合は、そのユーザは確認の対象外とする。
+ *
+ * -----
+ *
+ * ### サンプルパターン
+ * - 実行日時: 2022-01-30 12:00:00
+ * - 判定基準: 2022-01-23 12:00:00(実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前)
+ *
+ * #### パターン①
+ * - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト
+ * - モデレータB: lastActiveDate = 2022-01-23 12:00:00 ※セーフ(判定基準と同値なのでギリギリ残り0日)
+ * - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日)
+ * - モデレータD: lastActiveDate = null
+ *
+ * この場合、モデレータBのアクティビティのみ判定基準日よりも古くないため、モデレーターが在席と判断される。
+ *
+ * #### パターン②
+ * - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト
+ * - モデレータB: lastActiveDate = 2022-01-22 12:00:00 ※アウト(残り-1日)
+ * - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日)
+ * - モデレータD: lastActiveDate = null
+ *
+ * この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。
+ */
+ @bindThis
+ public async evaluateModeratorsInactiveDays() {
+ const today = new Date();
+ const inactivePeriod = new Date(today);
+ inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
+
+ const moderators = await this.fetchModerators()
+ .then(it => it.filter(it => it.lastActiveDate != null));
+ const inactiveModerators = moderators
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ .filter(it => it.lastActiveDate!.getTime() < inactivePeriod.getTime());
+
+ // 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime())));
+ const inactivityLimitCountdown = Math.floor((newestLastActiveDate.getTime() - inactivePeriod.getTime()) / ONE_DAY_MILLI_SEC);
+
+ return {
+ isModeratorsInactive: inactiveModerators.length === moderators.length,
+ inactiveModerators,
+ inactivityLimitCountdown,
+ };
+ }
+
+ @bindThis
+ private async changeToInvitationOnly() {
+ await this.metaService.update({ disableRegistration: true });
+ }
+
+ @bindThis
+ private async fetchModerators() {
+ // TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
+ return this.roleService.getModerators({
+ includeAdmins: true,
+ includeRoot: true,
+ excludeExpire: true,
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts
index 2fef9abbf9..2b2c8c60ab 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-users.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts
@@ -71,13 +71,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
break;
}
case 'moderator': {
- const moderatorIds = await this.roleService.getModeratorIds(false);
+ const moderatorIds = await this.roleService.getModeratorIds({ includeAdmins: false });
if (moderatorIds.length === 0) return [];
query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds });
break;
}
case 'adminOrModerator': {
- const adminOrModeratorIds = await this.roleService.getModeratorIds();
+ const adminOrModeratorIds = await this.roleService.getModeratorIds({ includeAdmins: true });
if (adminOrModeratorIds.length === 0) return [];
query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds });
break;
diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts
index ef80d25f81..9c1b1008d6 100644
--- a/packages/backend/test/unit/RoleService.ts
+++ b/packages/backend/test/unit/RoleService.ts
@@ -10,6 +10,8 @@ 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 {
@@ -31,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);
@@ -277,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 });
@@ -295,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 });
@@ -317,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 });
@@ -339,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 });
@@ -361,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/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
new file mode 100644
index 0000000000..b783320aa0
--- /dev/null
+++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
@@ -0,0 +1,235 @@
+/*
+ * 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 { MiUser, 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';
+
+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<RoleService>;
+
+ // --------------------------------------------------------------------------------------
+
+ async function createUser(data: Partial<MiUser> = {}) {
+ 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,
+ });
+
+ return user;
+ }
+
+ 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: 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<RoleService>;
+
+ app.enableShutdownHooks();
+ });
+
+ beforeEach(async () => {
+ clock = lolex.install({
+ now: new Date(baseDate),
+ shouldClearNativeTimers: true,
+ });
+ });
+
+ afterEach(async () => {
+ clock.uninstall();
+ await usersRepository.delete({});
+ await userProfilesRepository.delete({});
+ roleService.getModerators.mockReset();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ // --------------------------------------------------------------------------------------
+
+ describe('evaluateModeratorsInactiveDays', () => {
+ test('[isModeratorsInactive] inactiveなモデレーターがいても他のモデレーターがアクティブなら"運営が非アクティブ"としてみなされない', async () => {
+ const [user1, user2, user3, user4] = await Promise.all([
+ // 期限よりも1秒新しいタイミングでアクティブ化(セーフ)
+ createUser({ lastActiveDate: subDays(addSeconds(baseDate, 1), 7) }),
+ // 期限ちょうどにアクティブ化(セーフ)
+ createUser({ lastActiveDate: subDays(baseDate, 7) }),
+ // 期限よりも1秒古いタイミングでアクティブ化(アウト)
+ createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }),
+ // 対象外
+ createUser({ lastActiveDate: null }),
+ ]);
+
+ mockModeratorRole([user1, user2, user3, user4]);
+
+ const result = await service.evaluateModeratorsInactiveDays();
+ expect(result.isModeratorsInactive).toBe(false);
+ expect(result.inactiveModerators).toEqual([user3]);
+ });
+
+ test('[isModeratorsInactive] 全員非アクティブなら"運営が非アクティブ"としてみなされる', async () => {
+ const [user1, user2] = await Promise.all([
+ // 期限よりも1秒古いタイミングでアクティブ化(アウト)
+ createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }),
+ // 対象外
+ createUser({ lastActiveDate: null }),
+ ]);
+
+ mockModeratorRole([user1, user2]);
+
+ const result = await service.evaluateModeratorsInactiveDays();
+ expect(result.isModeratorsInactive).toBe(true);
+ expect(result.inactiveModerators).toEqual([user1]);
+ });
+
+ test('[countdown] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
+ const [user1, user2] = await Promise.all([
+ createUser({ lastActiveDate: subDays(baseDate, 8) }),
+ // 猶予はこのユーザ基準で計算される想定。
+ // 期限まで残り24時間->猶予1日として計算されるはずである
+ createUser({ lastActiveDate: subDays(baseDate, 6) }),
+ ]);
+
+ mockModeratorRole([user1, user2]);
+
+ const result = await service.evaluateModeratorsInactiveDays();
+ expect(result.isModeratorsInactive).toBe(false);
+ expect(result.inactiveModerators).toEqual([user1]);
+ expect(result.inactivityLimitCountdown).toBe(1);
+ });
+
+ test('[countdown] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
+ const [user1, user2] = await Promise.all([
+ createUser({ lastActiveDate: subDays(baseDate, 8) }),
+ // 猶予はこのユーザ基準で計算される想定。
+ // 期限まで残り25時間->猶予1日として計算されるはずである
+ createUser({ lastActiveDate: subDays(addHours(baseDate, 1), 6) }),
+ ]);
+
+ mockModeratorRole([user1, user2]);
+
+ const result = await service.evaluateModeratorsInactiveDays();
+ expect(result.isModeratorsInactive).toBe(false);
+ expect(result.inactiveModerators).toEqual([user1]);
+ expect(result.inactivityLimitCountdown).toBe(1);
+ });
+
+ test('[countdown] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
+ const [user1, user2] = await Promise.all([
+ createUser({ lastActiveDate: subDays(baseDate, 8) }),
+ // 猶予はこのユーザ基準で計算される想定。
+ // 期限まで残り23時間->猶予0日として計算されるはずである
+ createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 6) }),
+ ]);
+
+ mockModeratorRole([user1, user2]);
+
+ const result = await service.evaluateModeratorsInactiveDays();
+ expect(result.isModeratorsInactive).toBe(false);
+ expect(result.inactiveModerators).toEqual([user1]);
+ expect(result.inactivityLimitCountdown).toBe(0);
+ });
+
+ test('[countdown] 期限ちょうどの場合、猶予0日として計算される', async () => {
+ const [user1, user2] = await Promise.all([
+ createUser({ lastActiveDate: subDays(baseDate, 8) }),
+ // 猶予はこのユーザ基準で計算される想定。
+ // 期限ちょうど->猶予0日として計算されるはずである
+ createUser({ lastActiveDate: subDays(baseDate, 7) }),
+ ]);
+
+ mockModeratorRole([user1, user2]);
+
+ const result = await service.evaluateModeratorsInactiveDays();
+ expect(result.isModeratorsInactive).toBe(false);
+ expect(result.inactiveModerators).toEqual([user1]);
+ expect(result.inactivityLimitCountdown).toBe(0);
+ });
+
+ test('[countdown] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
+ const [user1, user2] = await Promise.all([
+ createUser({ lastActiveDate: subDays(baseDate, 8) }),
+ // 猶予はこのユーザ基準で計算される想定。
+ // 期限より1時間超過->猶予-1日として計算されるはずである
+ createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 7) }),
+ ]);
+
+ mockModeratorRole([user1, user2]);
+
+ const result = await service.evaluateModeratorsInactiveDays();
+ expect(result.isModeratorsInactive).toBe(true);
+ expect(result.inactiveModerators).toEqual([user1, user2]);
+ expect(result.inactivityLimitCountdown).toBe(-1);
+ });
+ });
+});