mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(threads): thread and forum API, admin, schemas and docs (#3251)
This commit is contained in:
1 parent
5f4295e399
commit
3f4160b138
495 files changed
+39232
-1215
No files matched your search
@@ -38,6 +38,7 @@ export const AdminAuditReadActions = {
|
||||
LIST_GUILD_MEMBERS: 'list_guild_members',
|
||||
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
|
||||
LIST_GUILD_STICKERS: 'list_guild_stickers',
|
||||
LIST_GUILD_THREADS: 'list_guild_threads',
|
||||
LIST_USER_APPLICATIONS: 'list_user_applications',
|
||||
LIST_USER_CHANGE_LOG: 'list_user_change_log',
|
||||
LIST_USER_DM_CHANNELS: 'list_user_dm_channels',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {enqueueRebuildThreadAutoArchiveQueue, enqueueThreadSearchBackfill} from '@app/api/channel/threads/ThreadJobs';
|
||||
import {
|
||||
type InstancePolicyConfig,
|
||||
REGISTRATION_PENDING_APPROVAL_TRAIT,
|
||||
@@ -14,6 +15,7 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {
|
||||
getChannelThreadsConfigPublisher,
|
||||
getGatewayRolloutConfigPublisher,
|
||||
getInstanceConfigRepository,
|
||||
getPushRelayConfigPublisher,
|
||||
@@ -21,6 +23,11 @@ import {
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {
|
||||
enabledThreadGuildIds,
|
||||
enqueueThreadPermissionSeeds,
|
||||
newlyEnabledThreadGuildIds,
|
||||
} from '@app/api/worker/tasks/SeedThreadPermissions';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
|
||||
@@ -35,6 +42,10 @@ import {
|
||||
PendingRegistrationActionRequest,
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
applyChannelThreadsConfigUpdate,
|
||||
type ChannelThreadsConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {PlutoniumPageConfigSchema} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
|
||||
@@ -69,6 +80,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
domainMigration,
|
||||
plutoniumPage,
|
||||
captcha,
|
||||
channelThreads,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -80,6 +92,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getPlutoniumPageConfig(),
|
||||
instanceConfigRepository.getCaptchaConfig(),
|
||||
instanceConfigRepository.getChannelThreadsConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -118,6 +131,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
domain_migration: domainMigration,
|
||||
plutonium_page: plutoniumPage,
|
||||
captcha,
|
||||
channel_threads: channelThreads,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -418,6 +432,25 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await instanceConfigRepository.updateCaptchaConfig(patch);
|
||||
}
|
||||
}
|
||||
let channelThreadsConfigVersion: number | undefined;
|
||||
if (data.channel_threads) {
|
||||
const patch = omitUndefinedFields(data.channel_threads);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
let previous: ChannelThreadsConfig | undefined;
|
||||
const landed = await instanceConfigRepository.updateChannelThreadsConfig((current) => {
|
||||
previous = current;
|
||||
return applyChannelThreadsConfigUpdate(current, patch);
|
||||
});
|
||||
channelThreadsConfigVersion = landed.config_version;
|
||||
await enqueueThreadPermissionSeeds(ctx.get('channelRepository').threads, landed);
|
||||
const newlyEnabled = previous ? newlyEnabledThreadGuildIds(previous, landed) : enabledThreadGuildIds(landed);
|
||||
for (const guildId of newlyEnabled) {
|
||||
await enqueueRebuildThreadAutoArchiveQueue(guildId);
|
||||
await enqueueThreadSearchBackfill(guildId);
|
||||
}
|
||||
await getChannelThreadsConfigPublisher().publish(landed);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const patch = data.experiment_delivery;
|
||||
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
|
||||
@@ -613,6 +646,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
action: 'update_instance_config',
|
||||
metadata: {
|
||||
sections: listSuppliedSections(data),
|
||||
channel_threads_config_version: channelThreadsConfigVersion?.toString(),
|
||||
granted_acls: grantedSetupCompleterAdmin ? AdminACLs.WILDCARD : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ export function SearchAdminController(app: HonoApp) {
|
||||
operationId: 'create_admin_search_index_refresh',
|
||||
summary: 'Refresh a search index',
|
||||
description:
|
||||
'Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages and guild_members indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.',
|
||||
'Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages, guild_members and threads indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: RefreshSearchIndexResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createChannelID, createGuildID} from '@app/api/BrandedTypes';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {AdminRateLimitConfigs} from '@app/api/rate_limit_configs/AdminRateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {ListGuildThreadsResponse} from '@fluxer/schema/src/domains/admin/AdminThreadSchemas';
|
||||
import {ChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
export function ThreadAdminController(app: HonoApp) {
|
||||
app.get(
|
||||
'/admin/guilds/:guild_id/threads',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_guild_threads',
|
||||
summary: 'List guild threads',
|
||||
description:
|
||||
'Lists every thread of a guild, active and archived, whether or not the channel threads experiment is active for it. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: ListGuildThreadsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
experiment: 'channel_threads',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const threads = await ctx.get('threadService').lists.listGuildThreadsForAdmin(guildId);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
action: AdminAuditReadActions.LIST_GUILD_THREADS,
|
||||
metadata: {result_count: threads.length},
|
||||
});
|
||||
return ctx.json({threads});
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
'/admin/channels/:channel_id',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_DELETE_ALL),
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_admin_thread_channel',
|
||||
summary: 'Delete a thread',
|
||||
description:
|
||||
'Deletes a thread channel with its messages and memberships. Only public and private threads can be deleted here. Requires MESSAGE_DELETE_ALL permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
experiment: 'channel_threads',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const thread = await ctx.get('channelRepository').findUnique(channelId);
|
||||
if (!thread) throw new UnknownChannelError();
|
||||
if (!thread.isThread()) throw new InvalidChannelTypeError();
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
await ctx.get('threadService').deletion.deleteThread({
|
||||
thread,
|
||||
actorId: adminUserId,
|
||||
auditLogReason: ctx.get('auditLogReason'),
|
||||
recordGuildAudit: false,
|
||||
});
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'channel',
|
||||
targetId: channelId,
|
||||
action: 'delete_thread',
|
||||
metadata: {guild_id: thread.guildId?.toString(), parent_id: thread.parentId?.toString(), type: thread.type},
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {ReportAdminController} from '@app/api/admin/controllers/ReportAdminContr
|
||||
import {SearchAdminController} from '@app/api/admin/controllers/SearchAdminController';
|
||||
import {StoreBillingAdminController} from '@app/api/admin/controllers/StoreBillingAdminController';
|
||||
import {SystemDmAdminController} from '@app/api/admin/controllers/SystemDmAdminController';
|
||||
import {ThreadAdminController} from '@app/api/admin/controllers/ThreadAdminController';
|
||||
import {UserAdminController} from '@app/api/admin/controllers/UserAdminController';
|
||||
import {VoiceAdminController} from '@app/api/admin/controllers/VoiceAdminController';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
@@ -30,6 +31,7 @@ export function registerAdminControllers(app: HonoApp) {
|
||||
StoreBillingAdminController(app);
|
||||
CodesAdminController(app);
|
||||
GuildAdminController(app);
|
||||
ThreadAdminController(app);
|
||||
AssetAdminController(app);
|
||||
BanAdminController(app);
|
||||
InstanceConfigAdminController(app);
|
||||
|
||||
@@ -13,17 +13,19 @@ import {
|
||||
type UserID,
|
||||
} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {withThreadContext} from '@app/api/channel/services/ChannelGatewayDispatch';
|
||||
import {
|
||||
enqueueCrosspostFamilyPurgeFromCopies,
|
||||
enqueueCrosspostSourceRemoval,
|
||||
} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {decrementThreadMessageCount, purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {
|
||||
createMessageResponseDataService,
|
||||
type MessageResponseAccessContext,
|
||||
messageResponseAccessForChannel,
|
||||
messageResponseAccessForGuild,
|
||||
} from '@app/api/channel/services/message/MessageResponseDataService';
|
||||
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
|
||||
import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {getPurgeQueue, getStorageService} from '@app/api/middleware/ServiceSingletons';
|
||||
@@ -144,15 +146,16 @@ export class AdminMessageService {
|
||||
message.authorId || createUserID(0n),
|
||||
message.pinnedTimestamp || undefined,
|
||||
);
|
||||
await decrementThreadMessageCount(channelRepository, channel, [messageId]);
|
||||
if (channel) {
|
||||
if (channel.guildId) {
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId: channel.guildId,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {
|
||||
data: withThreadContext(channel, {
|
||||
channel_id: channelId.toString(),
|
||||
id: messageId.toString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
@@ -349,9 +352,12 @@ export class AdminMessageService {
|
||||
guildName: null,
|
||||
};
|
||||
}
|
||||
const guild = await guildRepository.findUnique(channel.guildId);
|
||||
const [guild, scope] = await Promise.all([
|
||||
guildRepository.findUnique(channel.guildId),
|
||||
resolveNsfwScopeChannel(channel, (id) => channelRepository.findUnique(id)),
|
||||
]);
|
||||
return {
|
||||
channelNsfw: channel.isNsfw,
|
||||
channelNsfw: scope.isNsfw,
|
||||
guildNsfwLevel: guild?.nsfwLevel ?? null,
|
||||
channelName: channel.name ?? null,
|
||||
guildId: channel.guildId.toString(),
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
messageResponseAccessForChannel,
|
||||
messageResponseAccessForGuild,
|
||||
} from '@app/api/channel/services/message/MessageResponseDataService';
|
||||
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
|
||||
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
|
||||
import {SYSTEM_THREAD_VIEWER} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
@@ -205,6 +207,7 @@ export class AdminReportService {
|
||||
});
|
||||
await this.deps.channelService.messages.send.sendMessage({
|
||||
user: systemUser,
|
||||
viewer: SYSTEM_THREAD_VIEWER,
|
||||
channelId: dmChannel.id,
|
||||
data: {
|
||||
content: template.value.body,
|
||||
@@ -523,7 +526,10 @@ export class AdminReportService {
|
||||
return reportNsfwLookupCache.channelNsfwByChannelId.get(channelIdString) ?? null;
|
||||
}
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
const channelNsfw = channel?.isNsfw ?? null;
|
||||
const scope = channel
|
||||
? await resolveNsfwScopeChannel(channel, (id) => this.deps.channelRepository.findUnique(id))
|
||||
: null;
|
||||
const channelNsfw = scope?.isNsfw ?? null;
|
||||
reportNsfwLookupCache.channelNsfwByChannelId.set(channelIdString, channelNsfw);
|
||||
return channelNsfw;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {isSyntheticUserId} from '@app/api/constants/Core';
|
||||
import {channelThreadsEnabled} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getGuildSearchService, getUserSearchService} from '@app/api/SearchFactory';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
@@ -23,7 +25,8 @@ interface RefreshSearchIndexJobPayload extends WorkerJobPayload {
|
||||
| 'channel_messages'
|
||||
| 'favorite_memes'
|
||||
| 'guild_members'
|
||||
| 'discovery';
|
||||
| 'discovery'
|
||||
| 'threads';
|
||||
admin_user_id: string;
|
||||
audit_log_reason: string | null;
|
||||
job_id: string;
|
||||
@@ -175,7 +178,8 @@ export class AdminSearchService {
|
||||
| 'channel_messages'
|
||||
| 'guild_members'
|
||||
| 'favorite_memes'
|
||||
| 'discovery';
|
||||
| 'discovery'
|
||||
| 'threads';
|
||||
guild_id?: bigint;
|
||||
user_id?: bigint;
|
||||
},
|
||||
@@ -197,6 +201,15 @@ export class AdminSearchService {
|
||||
}
|
||||
payload.guild_id = data.guild_id.toString();
|
||||
}
|
||||
if (data.index_type === 'threads') {
|
||||
if (!channelThreadsEnabled()) {
|
||||
throw InputValidationError.fromCode('index_name', ValidationErrorCodes.INVALID_FORMAT);
|
||||
}
|
||||
if (!data.guild_id) {
|
||||
throw InputValidationError.create('guild_id', 'guild_id is required for the threads index type');
|
||||
}
|
||||
payload.guild_id = data.guild_id.toString();
|
||||
}
|
||||
if (data.index_type === 'guild_members') {
|
||||
if (!data.guild_id) {
|
||||
throw InputValidationError.create('guild_id', 'guild_id is required for the guild_members index type');
|
||||
|
||||
@@ -39,7 +39,7 @@ export class AdminGuildLookupService {
|
||||
return {guild: null};
|
||||
}
|
||||
const [channels, roles, ownerUser] = await Promise.all([
|
||||
channelRepository.listGuildChannels(guildId),
|
||||
channelRepository.listGuildChannels(guildId, 'maintenance'),
|
||||
guildRepository.listRoles(guildId),
|
||||
userRepository.findUnique(guild.ownerId),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {
|
||||
ALL_THREADS_ACTIVE,
|
||||
resetChannelThreadsConfig,
|
||||
setChannelThreadsConfig,
|
||||
threadsRequest,
|
||||
} from '@app/api/channel/tests/ThreadTestUtils';
|
||||
import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ServerMessageFlags} from '@fluxer/constants/src/ThreadConstants';
|
||||
import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface BrowseResponse {
|
||||
messages: Array<{id: string; channel_id: string}>;
|
||||
message_responses?: Array<MessageResponse>;
|
||||
}
|
||||
|
||||
describe('admin thread browse', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
resetChannelThreadsConfig();
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('returns thread messages and thread artifacts unmasked', async () => {
|
||||
await setChannelThreadsConfig(ALL_THREADS_ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
const guild = await createGuild(harness, owner.token, 'browse');
|
||||
const channel = await createChannel(harness, owner.token, guild.id, 'general');
|
||||
const source = await sendMessage(harness, owner.token, channel.id, 'source');
|
||||
await threadsRequest(harness, owner.token)
|
||||
.post(`/channels/${channel.id}/messages/${source.id}/threads`)
|
||||
.body({name: 'from source'})
|
||||
.expect(201)
|
||||
.execute();
|
||||
const standalone = await threadsRequest<ThreadChannelResponse>(harness, owner.token)
|
||||
.post(`/channels/${channel.id}/threads`)
|
||||
.body({name: 'standalone', type: ChannelTypes.PUBLIC_THREAD})
|
||||
.expect(201)
|
||||
.execute();
|
||||
const inThread = await threadsRequest<MessageResponse>(harness, owner.token)
|
||||
.post(`/channels/${standalone.id}/messages`)
|
||||
.body({content: 'inside'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.MESSAGE_LOOKUP,
|
||||
]);
|
||||
const parent = await createBuilder<BrowseResponse>(harness, admin.token)
|
||||
.get(`/admin/channels/${channel.id}/messages?limit=50`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
const responses = parent.message_responses ?? [];
|
||||
const sourceResponse = responses.find((message) => message.id === source.id);
|
||||
expect((sourceResponse?.flags ?? 0) & ServerMessageFlags.HAS_THREAD).toBe(ServerMessageFlags.HAS_THREAD);
|
||||
expect(responses.some((message) => message.type === MessageTypes.THREAD_CREATED)).toBe(true);
|
||||
const thread = await createBuilder<BrowseResponse>(harness, admin.token)
|
||||
.get(`/admin/channels/${standalone.id}/messages?limit=50`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(thread.messages.map((message) => message.id)).toContain(inThread.id);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {ReportAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ReportA
|
||||
import {SearchAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SearchAdminAuditCases';
|
||||
import {StoreBillingAdminAuditCases} from '@app/api/admin/tests/audit_coverage/StoreBillingAdminAuditCases';
|
||||
import {SystemDmAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SystemDmAdminAuditCases';
|
||||
import {ThreadAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ThreadAdminAuditCases';
|
||||
import {UserAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserAdminAuditCases';
|
||||
import {UserWriteAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserWriteAdminAuditCases';
|
||||
import {VoiceAdminAuditCases} from '@app/api/admin/tests/audit_coverage/VoiceAdminAuditCases';
|
||||
@@ -48,6 +49,7 @@ const ALL_CASES = [
|
||||
...SearchAdminAuditCases,
|
||||
...StoreBillingAdminAuditCases,
|
||||
...SystemDmAdminAuditCases,
|
||||
...ThreadAdminAuditCases,
|
||||
...UserAdminAuditCases,
|
||||
...UserWriteAdminAuditCases,
|
||||
...VoiceAdminAuditCases,
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import type {CreateRegistrationUrlResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {vi} from 'vitest';
|
||||
|
||||
async function createRegistrationUrl(harness: ApiTestHarness, admin: TestAccount): Promise<string> {
|
||||
const created = await createBuilder<CreateRegistrationUrlResponse>(harness, admin.token)
|
||||
@@ -80,6 +82,26 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/instance/config',
|
||||
name: 'channel threads',
|
||||
async prepare() {
|
||||
vi.spyOn(ChannelThreadsConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/instance/config',
|
||||
body: {channel_threads: {enabled: true, enabled_guild_ids: ['1']}},
|
||||
},
|
||||
expected: {
|
||||
action: 'update_instance_config',
|
||||
targetType: 'instance_config',
|
||||
targetId: '0',
|
||||
metadata: {sections: 'channel_threads', channel_threads_config_version: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/instance/config/branding-assets',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {
|
||||
ALL_THREADS_ACTIVE,
|
||||
resetChannelThreadsConfig,
|
||||
setChannelThreadsConfig,
|
||||
threadsRequest,
|
||||
} from '@app/api/channel/tests/ThreadTestUtils';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas';
|
||||
|
||||
export const ThreadAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/guilds/:guild_id/threads',
|
||||
async prepare({harness, admin}) {
|
||||
resetChannelThreadsConfig();
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Thread List Guild');
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/threads`},
|
||||
expected: {
|
||||
action: 'list_guild_threads',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {result_count: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/channels/:channel_id',
|
||||
async prepare({harness, admin}) {
|
||||
resetChannelThreadsConfig();
|
||||
await setChannelThreadsConfig(ALL_THREADS_ACTIVE);
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Thread Delete Guild');
|
||||
const channel = await createChannel(harness, admin.token, guild.id, 'general');
|
||||
const thread = await threadsRequest<ThreadChannelResponse>(harness, admin.token)
|
||||
.post(`/channels/${channel.id}/threads`)
|
||||
.body({name: 'doomed', type: ChannelTypes.PUBLIC_THREAD})
|
||||
.expect(201)
|
||||
.execute();
|
||||
return {
|
||||
request: {path: `/admin/channels/${thread.id}`, expectStatus: 204},
|
||||
expected: {
|
||||
action: 'delete_thread',
|
||||
targetType: 'channel',
|
||||
targetId: thread.id,
|
||||
metadata: {guild_id: guild.id, parent_id: channel.id, type: String(ChannelTypes.PUBLIC_THREAD)},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {ThreadAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ThreadAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('ThreadAdminController', ThreadAdminAuditCases);
|
||||
Reference in new issue
Block a user