mirror of
https://activitypub.software/TransFem-org/Sharkey
synced 2024-11-22 05:55:12 +00:00
feat(backend): 7日間運営のアクティビティがないサーバを自動的に招待制にする (#14746)
* feat(backend): 7日間運営のアクティビティがないサーバを自動的に招待制にする * fix RoleService. * fix * fix * fix * add test and fix * fix * fix CHANGELOG.md * fix test
This commit is contained in:
parent
e783359aca
commit
d132c8257e
9 changed files with 570 additions and 44 deletions
|
@ -59,7 +59,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) {
|
||||
|
@ -348,7 +351,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) {
|
||||
|
|
|
@ -94,6 +94,13 @@ export class QueueService {
|
|||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.systemQueue.add('checkModeratorsActivity', {
|
||||
}, {
|
||||
// 毎時30分に起動
|
||||
repeat: { pattern: '30 * * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -105,6 +105,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;
|
||||
|
@ -140,6 +141,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
|
||||
|
||||
|
@ -422,49 +424,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),
|
||||
);
|
||||
|
||||
return result.sort((x, y) => x.localeCompare(y));
|
||||
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 [...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
|
||||
|
|
|
@ -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';
|
||||
|
@ -84,6 +85,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
|
|||
DeliverProcessorService,
|
||||
InboxProcessorService,
|
||||
AggregateRetentionProcessorService,
|
||||
CheckExpiredMutingsProcessorService,
|
||||
CheckModeratorsActivityProcessorService,
|
||||
QueueProcessorService,
|
||||
],
|
||||
exports: [
|
||||
|
|
|
@ -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';
|
||||
|
@ -124,6 +125,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;
|
||||
|
@ -164,6 +166,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`);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -72,13 +72,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;
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue