fix(api): SSO discovery, channel create and deleted bots (#3390)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 13:31:18 +02:00
1 parent edc913096a
commit 0967bf3136
17 files changed
+508 -156

No files matched your search

@@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {tryDiscoverOidcProviderMetadata} from '@app/api/auth/services/SsoUtils';
import {Config} from '@app/api/Config';
import {resetSsoRequestUrlPolicyForTesting} from '@app/api/instance/SsoConfigValidation';
import {server} from '@app/api/test/msw/server';
import {HttpResponse, http} from 'msw';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
const PRIVATE_ISSUER = 'http://10.20.30.40:5556/dex';
describe('tryDiscoverOidcProviderMetadata', () => {
const previousAllowPrivateAddresses = Config.auth.ssoAllowPrivateAddresses;
let discoveryRequests = 0;
beforeEach(() => {
discoveryRequests = 0;
server.use(
http.get(`${PRIVATE_ISSUER}/.well-known/openid-configuration`, () => {
discoveryRequests++;
return HttpResponse.json({
issuer: PRIVATE_ISSUER,
authorization_endpoint: `${PRIVATE_ISSUER}/auth`,
token_endpoint: `${PRIVATE_ISSUER}/token`,
userinfo_endpoint: `${PRIVATE_ISSUER}/userinfo`,
jwks_uri: `${PRIVATE_ISSUER}/keys`,
});
}),
);
});
afterEach(() => {
Config.auth.ssoAllowPrivateAddresses = previousAllowPrivateAddresses;
resetSsoRequestUrlPolicyForTesting();
});
it('discovers endpoints on a private issuer when private addresses are allowed', async () => {
Config.auth.ssoAllowPrivateAddresses = true;
resetSsoRequestUrlPolicyForTesting();
await expect(tryDiscoverOidcProviderMetadata(PRIVATE_ISSUER)).resolves.toEqual({
issuer: PRIVATE_ISSUER,
authorization_endpoint: `${PRIVATE_ISSUER}/auth`,
token_endpoint: `${PRIVATE_ISSUER}/token`,
userinfo_endpoint: `${PRIVATE_ISSUER}/userinfo`,
jwks_uri: `${PRIVATE_ISSUER}/keys`,
});
expect(discoveryRequests).toBe(1);
});
it('refuses a private issuer when private addresses are not allowed', async () => {
Config.auth.ssoAllowPrivateAddresses = false;
resetSsoRequestUrlPolicyForTesting();
await expect(tryDiscoverOidcProviderMetadata(PRIVATE_ISSUER)).resolves.toBeNull();
expect(discoveryRequests).toBe(0);
});
});
+11 -7
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getSsoRequestUrlPolicy} from '@app/api/instance/SsoConfigValidation';
import {Logger} from '@app/api/Logger';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
@@ -68,13 +69,16 @@ function normalizeIssuerForCompare(value: string): string {
export async function tryDiscoverOidcProviderMetadata(issuer: string): Promise<DiscoveredOidcProviderMetadata | null> {
try {
const url = buildDiscoveryUrl(issuer);
const resp = await FetchUtils.sendRequest({
url: url.toString(),
method: 'GET',
headers: {Accept: 'application/json'},
timeout: ms('10 seconds'),
serviceName: 'sso_oidc_discovery',
});
const resp = await FetchUtils.sendRequest(
{
url: url.toString(),
method: 'GET',
headers: {Accept: 'application/json'},
timeout: ms('10 seconds'),
serviceName: 'sso_oidc_discovery',
},
{requestUrlPolicy: getSsoRequestUrlPolicy()},
);
if (resp.status < 200 || resp.status >= 300) {
FetchUtils.discardResponseBody(resp.stream, resp.status);
return null;
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createWebhookID} from '@app/api/BrandedTypes';
import {resetApiServicesForTesting} from '@app/api/CreateApiContext';
import {
@@ -36,11 +36,8 @@ import syncCrosspostCopies from '@app/api/worker/tasks/SyncCrosspostCopies';
import syncCrosspostedMessage from '@app/api/worker/tasks/SyncCrosspostedMessage';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import type {WorkerDependencies} from '@app/api/worker/WorkerDependencies';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import type {FollowedChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelFollowSchemas';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
@@ -212,30 +209,6 @@ export async function setGuildFeatures(
.execute();
}
export async function setLimitOverride(
harness: ApiTestHarness,
limits: Partial<Record<LimitKey, number>>,
): Promise<() => Promise<void>> {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
]);
const current = await createBuilder<{limit_config: LimitConfigSnapshot}>(harness, admin.token)
.get('/admin/limit-config')
.execute();
const writeConfig = async (rules: LimitConfigSnapshot['rules']) => {
await createBuilder(harness, admin.token)
.put('/admin/limit-config')
.body({limit_config: {traitDefinitions: current.limit_config.traitDefinitions, rules}})
.execute();
};
await writeConfig([...current.limit_config.rules, {id: 'announcement_test_override', limits}]);
return async () => {
await writeConfig(current.limit_config.rules);
};
}
const crosspostTaskHandlers: Record<string, WorkerTaskHandler> = {
crosspostMessage,
crosspostMessageChunk,
@@ -0,0 +1,216 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
addMemberRole,
createChannel,
createPermissionOverwrite,
createRole,
setupTestGuildWithMembers,
} from '@app/api/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {setLimitOverride} from '@app/api/test/LimitTestUtils';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
describe('Channel create in a category', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
async function setupCategoryManager(extraAllow = 0n) {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
const category = await createChannel(harness, owner.token, guild.id, 'managed', ChannelTypes.GUILD_CATEGORY);
const otherCategory = await createChannel(harness, owner.token, guild.id, 'other', ChannelTypes.GUILD_CATEGORY);
const role = await createRole(harness, owner.token, guild.id, {name: 'creators'});
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
await createPermissionOverwrite(harness, owner.token, category.id, role.id, {
type: 0,
allow: (Permissions.VIEW_CHANNEL | Permissions.MANAGE_CHANNELS | extraAllow).toString(),
deny: '0',
});
return {owner, member, guild, role, category, otherCategory};
}
it('lets a category overwrite grant MANAGE_CHANNELS for that category only', async () => {
const {member, guild, category, otherCategory} = await setupCategoryManager();
const created = await createBuilder<ChannelResponse>(harness, member.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'member-made', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
.execute();
expect(created.parent_id).toBe(category.id);
await createBuilder(harness, member.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'root-made', type: ChannelTypes.GUILD_TEXT})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
.execute();
await createBuilder(harness, member.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'elsewhere', type: ChannelTypes.GUILD_TEXT, parent_id: otherCategory.id})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
.execute();
});
it('honors a category deny of MANAGE_CHANNELS for a guild-wide manager', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
const category = await createChannel(harness, owner.token, guild.id, 'locked', ChannelTypes.GUILD_CATEGORY);
const role = await createRole(harness, owner.token, guild.id, {
name: 'managers',
permissions: Permissions.MANAGE_CHANNELS.toString(),
});
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
await createPermissionOverwrite(harness, owner.token, category.id, role.id, {
type: 0,
allow: '0',
deny: Permissions.MANAGE_CHANNELS.toString(),
});
await createBuilder(harness, member.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'denied', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
.execute();
});
it('lets a category overwrite grant MANAGE_ROLES for requested overwrites within its own permissions', async () => {
const {member, guild, role, category} = await setupCategoryManager(Permissions.MANAGE_ROLES);
await createBuilder<ChannelResponse>(harness, member.token)
.post(`/guilds/${guild.id}/channels`)
.body({
name: 'with-overwrites',
type: ChannelTypes.GUILD_TEXT,
parent_id: category.id,
permission_overwrites: [{id: role.id, type: 0, allow: Permissions.VIEW_CHANNEL.toString(), deny: '0'}],
})
.execute();
await createBuilder(harness, member.token)
.post(`/guilds/${guild.id}/channels`)
.body({
name: 'escalate',
type: ChannelTypes.GUILD_TEXT,
parent_id: category.id,
permission_overwrites: [{id: role.id, type: 0, allow: Permissions.ADMINISTRATOR.toString(), deny: '0'}],
})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
.execute();
});
it('rejects requested overwrites without MANAGE_ROLES on the category', async () => {
const {member, guild, role, category} = await setupCategoryManager();
await createBuilder(harness, member.token)
.post(`/guilds/${guild.id}/channels`)
.body({
name: 'no-roles',
type: ChannelTypes.GUILD_TEXT,
parent_id: category.id,
permission_overwrites: [{id: role.id, type: 0, allow: Permissions.VIEW_CHANNEL.toString(), deny: '0'}],
})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
.execute();
});
it('applies a configured max_channels_per_category on create', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const category = await createChannel(harness, owner.token, guild.id, 'full', ChannelTypes.GUILD_CATEGORY);
const restore = await setLimitOverride(harness, {max_channels_per_category: 0});
try {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'overflow', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS)
.execute();
await createChannel(harness, owner.token, guild.id, 'at-root');
} finally {
await restore();
}
});
it('applies a configured max_guild_channels on create', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const restore = await setLimitOverride(harness, {max_guild_channels: 0});
try {
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'overflow', type: ChannelTypes.GUILD_TEXT})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_CHANNELS)
.execute();
} finally {
await restore();
}
});
it('lets a raised max_channels_per_category admit a channel past the default', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const category = await createChannel(harness, owner.token, guild.id, 'busy', ChannelTypes.GUILD_CATEGORY);
vi.spyOn(NoopGatewayService.prototype, 'getCategoryChannelCount').mockResolvedValue(50);
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'fifty-first', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS)
.execute();
const restore = await setLimitOverride(harness, {max_channels_per_category: 500});
try {
const created = await createBuilder<ChannelResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'fifty-first', type: ChannelTypes.GUILD_TEXT, parent_id: category.id})
.execute();
expect(created.parent_id).toBe(category.id);
} finally {
await restore();
}
});
it('lets a raised max_guild_channels admit a channel past the default', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
vi.spyOn(NoopGatewayService.prototype, 'getChannelCount').mockResolvedValue(500);
await createBuilder(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'one-more', type: ChannelTypes.GUILD_TEXT})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_CHANNELS)
.execute();
const restore = await setLimitOverride(harness, {max_guild_channels: 1000});
try {
await createChannel(harness, owner.token, guild.id, 'one-more');
} finally {
await restore();
}
});
it('applies a configured max_channels_per_category when a channel is moved into a category', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const category = await createChannel(harness, owner.token, guild.id, 'target', ChannelTypes.GUILD_CATEGORY);
const channel = await createChannel(harness, owner.token, guild.id, 'mover');
const move = () =>
createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/channels`)
.body([{id: channel.id, parent_id: category.id}]);
const restoreLowered = await setLimitOverride(harness, {max_channels_per_category: 0});
try {
await move().expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS).execute();
} finally {
await restoreLowered();
}
vi.spyOn(NoopGatewayService.prototype, 'getCategoryChannelCount').mockResolvedValue(50);
await move().expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_CATEGORY_CHANNELS).execute();
const restoreRaised = await setLimitOverride(harness, {max_channels_per_category: 500});
try {
await move().expect(HTTP_STATUS.NO_CONTENT).execute();
} finally {
await restoreRaised();
}
const moved = await createBuilder<ChannelResponse>(harness, owner.token).get(`/channels/${channel.id}`).execute();
expect(moved.parent_id).toBe(category.id);
});
});
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
import {type ChannelID, createChannelID, type GuildID, type UserID} from '@app/api/BrandedTypes';
import {mapChannelToResponse} from '@app/api/channel/ChannelMappers';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {withThreadParentFieldsMany} from '@app/api/channel/services/thread/ThreadParentSettings';
@@ -112,6 +112,7 @@ export class GuildChannelService {
userId: params.userId,
guildId: params.guildId,
permission: Permissions.MANAGE_CHANNELS,
channelId: params.data.parent_id ? createChannelID(params.data.parent_id) : undefined,
});
const response = await this.channelOps.createChannel(params, auditLogReason);
const [masked] = await maskChannelResponseThreadBits(params.guildId, params.viewer, [response]);
@@ -155,11 +156,17 @@ export class GuildChannelService {
await this.channelOps.sanitizeTextChannelNames(params);
}
private async checkPermission(params: {userId: UserID; guildId: GuildID; permission: bigint}): Promise<void> {
private async checkPermission(params: {
userId: UserID;
guildId: GuildID;
permission: bigint;
channelId?: ChannelID;
}): Promise<void> {
const hasPermission = await this.gatewayService.checkPermission({
guildId: params.guildId,
userId: params.userId,
permission: params.permission,
channelId: params.channelId,
});
if (!hasPermission) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
@@ -104,6 +104,7 @@ export class ChannelOperationsService {
guildId: params.guildId,
userId: params.userId,
permission: Permissions.MANAGE_ROLES,
channelId: parentId ?? undefined,
});
if (!canManageRoles) throw new MissingPermissionsError();
const basePermissions = await this.gatewayService.getUserPermissions({
@@ -736,6 +737,7 @@ export class ChannelOperationsService {
ctx,
'max_channels_per_category',
maxChannels,
'guild',
);
if (count >= maxChannels) {
throw new MaxCategoryChannelsError(maxChannels);
@@ -747,7 +749,13 @@ export class ChannelOperationsService {
let maxChannels = MAX_GUILD_CHANNELS;
const guild = await this.guildRepository.findUnique(guildId);
const ctx = createLimitMatchContext({user: null, guildFeatures: guild?.features ?? null});
maxChannels = resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, 'max_guild_channels', maxChannels);
maxChannels = resolveLimitSafe(
this.limitConfigService.getConfigSnapshot(),
ctx,
'max_guild_channels',
maxChannels,
'guild',
);
if (count >= maxChannels) {
throw new MaxGuildChannelsError(maxChannels);
}
@@ -527,7 +527,6 @@ class RequestServices implements RequestScopedServices {
get applicationService(): ApplicationService {
this.cachedApplicationService ??= new ApplicationService(this.context, {
applicationRepository: getApplicationRepository(),
channelRepository: getChannelRepository(),
userCacheService: getUserCacheService(),
entityAssetService: getEntityAssetService(),
discriminatorService: getDiscriminatorService(),
@@ -1,62 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {MessageAnonymizationService} from '@app/api/channel/services/message/MessageAnonymizationService';
import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import {Logger} from '@app/api/Logger';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {
DELETED_USER_DISCRIMINATOR,
DELETED_USER_GLOBAL_NAME,
DELETED_USER_USERNAME,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
interface RemapAuthorMessagesToDeletedUserParams {
originalAuthorId: UserID;
channelRepository: ChannelRepository;
userRepository: UserRepository;
snowflakeService: ISnowflakeService;
}
async function createDeletedMessageAuthorUser(params: {
userRepository: UserRepository;
snowflakeService: ISnowflakeService;
}): Promise<UserID> {
const deletedUserId = createUserID(await params.snowflakeService.generate());
await params.userRepository.create({
...EMPTY_USER_ROW,
user_id: deletedUserId,
username: DELETED_USER_USERNAME,
discriminator: DELETED_USER_DISCRIMINATOR,
global_name: DELETED_USER_GLOBAL_NAME,
bot: false,
system: false,
flags: UserFlags.DELETED,
});
await params.userRepository.deleteUserSecondaryIndices(deletedUserId);
return deletedUserId;
}
export async function remapAuthorMessagesToDeletedUser(
params: RemapAuthorMessagesToDeletedUserParams,
): Promise<UserID | null> {
const {originalAuthorId, channelRepository, userRepository, snowflakeService} = params;
const hasMessages = await channelRepository.messages.listMessagesByAuthor(originalAuthorId, 1);
if (hasMessages.length === 0) {
return null;
}
const replacementAuthorId = await createDeletedMessageAuthorUser({
userRepository,
snowflakeService,
});
const anonymizationService = new MessageAnonymizationService(channelRepository);
await anonymizationService.anonymizeMessagesByAuthor(originalAuthorId, replacementAuthorId);
Logger.info(
{originalAuthorId: originalAuthorId.toString(), replacementAuthorId: replacementAuthorId.toString()},
'Remapped authored messages to deleted user id',
);
return replacementAuthorId;
}
@@ -3,7 +3,6 @@
import type {ApiContext} from '@app/api/ApiContext';
import type {ApplicationID, UserID} from '@app/api/BrandedTypes';
import {applicationIdToUserId} from '@app/api/BrandedTypes';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import type {ApplicationRow} from '@app/api/database/types/OAuth2Types';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
@@ -14,7 +13,6 @@ import {Logger} from '@app/api/Logger';
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
import type {Application} from '@app/api/models/Application';
import type {User} from '@app/api/models/User';
import {remapAuthorMessagesToDeletedUser} from '@app/api/oauth/ApplicationMessageAuthorAnonymization';
import type {BotAuthService} from '@app/api/oauth/BotAuthService';
import {generateOAuthTokenSecret} from '@app/api/oauth/OAuthTokenSecret';
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
@@ -43,7 +41,6 @@ import type {BotProfileUpdateRequest} from '@fluxer/schema/src/domains/oauth/OAu
interface ApplicationServiceDeps {
discriminatorService: DiscriminatorService;
channelRepository: ChannelRepository;
applicationRepository: ApplicationRepository;
botAuthService: BotAuthService;
entityAssetService: EntityAssetService;
@@ -307,12 +304,6 @@ export class ApplicationService {
const application = await this.verifyOwnership(userId, applicationId);
if (application.hasBotUser()) {
const botUserId = application.getBotUserId()!;
const replacementAuthorId = await remapAuthorMessagesToDeletedUser({
originalAuthorId: botUserId,
channelRepository: this.deps.channelRepository,
userRepository: this.apiContext.services.users,
snowflakeService: this.apiContext.services.snowflake,
});
const guildIds = await this.apiContext.services.users.getUserGuildIds(botUserId);
await this.apiContext.services.users.deleteUserSecondaryIndices(botUserId);
await this.apiContext.services.users.removeFromAllGuilds(botUserId);
@@ -372,14 +363,10 @@ export class ApplicationService {
botUser.toRow(),
);
await this.deps.userCacheService.invalidateUserCache(botUserId);
if (replacementAuthorId) {
await this.deps.userCacheService.invalidateUserCache(replacementAuthorId);
}
Logger.info(
{
applicationId: applicationId.toString(),
botUserId: botUserId.toString(),
replacementAuthorId: replacementAuthorId?.toString() ?? null,
},
'Anonymized bot user associated with application',
);
@@ -94,25 +94,17 @@ describe('OAuth2 Application Delete', () => {
.execute();
const foundMessage = messages.find((message) => message.id === botMessageId);
expect(foundMessage).toBeDefined();
expect(foundMessage?.author.id).not.toBe(createResult.botUserId);
expect(foundMessage?.author.id).toBe(createResult.botUserId);
expect(foundMessage?.author.bot).toBe(true);
expect(foundMessage?.author.username).toBe('DeletedUser');
expect(foundMessage?.author.discriminator).toBe('0000');
const previousAuthorCount = await createBuilderWithoutAuth<{
const authorCount = await createBuilderWithoutAuth<{
count: number;
}>(harness)
.get(`/test/users/${createResult.botUserId}/messages/count`)
.expect(HTTP_STATUS.OK)
.execute();
expect(previousAuthorCount.count).toBe(0);
const replacementAuthorId = foundMessage?.author.id;
expect(replacementAuthorId).toBeTruthy();
const replacementAuthorCount = await createBuilderWithoutAuth<{
count: number;
}>(harness)
.get(`/test/users/${replacementAuthorId}/messages/count`)
.expect(HTTP_STATUS.OK)
.execute();
expect(replacementAuthorCount.count).toBe(1);
expect(authorCount.count).toBe(1);
});
test('enforces access control', async () => {
const owner = await createTestAccount(harness);
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
export async function setLimitOverride(
harness: ApiTestHarness,
limits: Partial<Record<LimitKey, number>>,
): Promise<() => Promise<void>> {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
]);
const current = await createBuilder<{limit_config: LimitConfigSnapshot}>(harness, admin.token)
.get('/admin/limit-config')
.execute();
const writeConfig = async (rules: LimitConfigSnapshot['rules']) => {
await createBuilder(harness, admin.token)
.put('/admin/limit-config')
.body({limit_config: {traitDefinitions: current.limit_config.traitDefinitions, rules}})
.execute();
};
await writeConfig([...current.limit_config.rules, {id: 'test_limit_override', limits}]);
return async () => {
await writeConfig(current.limit_config.rules);
};
}
@@ -11,13 +11,13 @@ import {
follow,
followRequest,
setGuildFeatures,
setLimitOverride,
} from '@app/api/channel/tests/AnnouncementTestUtils';
import {createGuild, createPermissionOverwrite, updateGuild} from '@app/api/channel/tests/ChannelTestUtils';
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {setLimitOverride} from '@app/api/test/LimitTestUtils';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
@@ -4,7 +4,6 @@ import {applicationIdToUserId, createApplicationID, type GuildID} from '@app/api
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
import {Logger} from '@app/api/Logger';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {remapAuthorMessagesToDeletedUser} from '@app/api/oauth/ApplicationMessageAuthorAnonymization';
import {chunkArray} from '@app/api/utils/ArrayUtils';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import {
@@ -25,15 +24,8 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
helpers.logger.debug({payload: validated}, 'Processing applicationProcessDeletion task');
const applicationId = createApplicationID(BigInt(validated.applicationId));
const botUserId = applicationIdToUserId(applicationId);
const {
userRepository,
guildRepository,
channelRepository,
applicationRepository,
userCacheService,
gatewayService,
snowflakeService,
} = getWorkerDependencies();
const {userRepository, guildRepository, applicationRepository, userCacheService, gatewayService} =
getWorkerDependencies();
Logger.debug({applicationId, botUserId}, 'Starting application deletion');
try {
const application = await applicationRepository.getApplication(applicationId);
@@ -42,21 +34,8 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
return;
}
const botUser = await userRepository.findUniqueAssert(botUserId);
const replacementAuthorId = await remapAuthorMessagesToDeletedUser({
originalAuthorId: botUserId,
channelRepository,
userRepository,
snowflakeService,
});
if (botUser.flags & UserFlags.DELETED) {
Logger.info(
{
applicationId,
botUserId,
replacementAuthorId: replacementAuthorId?.toString() ?? null,
},
'Bot user already marked as deleted, skipping profile update',
);
Logger.info({applicationId, botUserId}, 'Bot user already marked as deleted, skipping profile update');
await applicationRepository.deleteApplication(applicationId);
return;
}
@@ -66,6 +45,11 @@ const applicationProcessDeletion: WorkerTaskHandler = async (payload, helpers) =
username: DELETED_USER_USERNAME,
global_name: DELETED_USER_GLOBAL_NAME,
discriminator: DELETED_USER_DISCRIMINATOR,
avatar_hash: null,
banner_hash: null,
bio: null,
pronouns: null,
accent_color: null,
},
botUser.toRow(),
);
@@ -0,0 +1,139 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createApplicationID, createUserID} from '@app/api/BrandedTypes';
import {getGatewayService} from '@app/api/middleware/ServiceRegistry';
import {
createUserCacheService,
getApplicationRepository,
getGuildRepository,
getUserRepository,
} from '@app/api/middleware/ServiceSingletons';
import {createOAuth2Application, createUniqueApplicationName} from '@app/api/oauth/tests/OAuth2TestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import applicationProcessDeletion from '@app/api/worker/tasks/ApplicationProcessDeletion';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
function createHelpers(): WorkerTaskHelpers {
return {
logger: new NoopLogger(),
jobId: 7200000000000000000n,
addJob: async () => 0n,
reportProgress: async () => {},
shouldCancel: async () => false,
setContextLink: async () => {},
};
}
describe('applicationProcessDeletion task', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
});
beforeEach(async () => {
await harness.resetData();
setWorkerDependenciesForTest({
userRepository: getUserRepository(),
guildRepository: getGuildRepository(),
applicationRepository: getApplicationRepository(),
userCacheService: createUserCacheService(),
gatewayService: getGatewayService(),
});
});
afterEach(() => {
clearWorkerDependencies();
});
test('keeps the bot as author of its messages and clears its profile', async () => {
const account = await createTestAccount(harness);
const createResult = await createOAuth2Application(harness, account.token, {
name: createUniqueApplicationName(),
});
const botUserId = createUserID(BigInt(createResult.botUserId));
const userRepository = getUserRepository();
const botUser = await userRepository.findUniqueAssert(botUserId);
await userRepository.patchUpsert(
botUserId,
{
avatar_hash: 'a'.repeat(32),
banner_hash: 'b'.repeat(32),
bio: 'bot bio',
pronouns: 'it/its',
accent_color: 0x4641d9,
},
botUser.toRow(),
);
const guild = await createBuilder<{
id: string;
system_channel_id?: string;
}>(harness, account.token)
.post('/guilds')
.body({name: `Bot Message Retention ${Date.now()}`})
.expect(HTTP_STATUS.OK)
.execute();
const channelId = guild.system_channel_id;
if (!channelId) {
throw new Error('Guild response missing system channel');
}
const seeded = await createBuilder<{
messages: Array<{
message_id: string;
}>;
}>(harness, '')
.post('/test/messages/seed')
.body({
channel_id: channelId,
author_id: createResult.botUserId,
clear_existing: true,
messages: [{content: 'bot message before deletion'}],
})
.expect(HTTP_STATUS.OK)
.execute();
const botMessageId = seeded.messages[0]?.message_id;
if (!botMessageId) {
throw new Error('Seeded message response missing message id');
}
await applicationProcessDeletion({applicationId: createResult.application.id}, createHelpers());
expect(
await getApplicationRepository().getApplication(createApplicationID(BigInt(createResult.application.id))),
).toBeNull();
const deletedBot = await userRepository.findUniqueAssert(botUserId);
expect(deletedBot.isBot).toBe(true);
expect(deletedBot.flags & UserFlags.DELETED).toBe(UserFlags.DELETED);
expect(deletedBot.username).toBe('DeletedUser');
expect(deletedBot.avatarHash).toBeNull();
expect(deletedBot.bannerHash).toBeNull();
expect(deletedBot.bio).toBeNull();
expect(deletedBot.pronouns).toBeNull();
expect(deletedBot.accentColor).toBeNull();
const messages = await createBuilder<Array<MessageResponse>>(harness, account.token)
.get(`/channels/${channelId}/messages`)
.expect(HTTP_STATUS.OK)
.execute();
const foundMessage = messages.find((message) => message.id === botMessageId);
expect(foundMessage?.author.id).toBe(createResult.botUserId);
expect(foundMessage?.author.bot).toBe(true);
expect(foundMessage?.author.username).toBe('DeletedUser');
expect(foundMessage?.author.discriminator).toBe('0000');
expect(foundMessage?.author.avatar).toBeNull();
const authorCount = await createBuilderWithoutAuth<{
count: number;
}>(harness)
.get(`/test/users/${createResult.botUserId}/messages/count`)
.expect(HTTP_STATUS.OK)
.execute();
expect(authorCount.count).toBe(1);
});
});