From bec34ea147551b992a238c6761ef1a4e9f81aee2 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 6 Sep 2026 15:06:15 +0200 Subject: [PATCH] fix(api)!: correct declared bounds and hide public bot mfa (#2485) --- fluxer_admin/openapi-admin.json | 2 +- fluxer_api/src/api/oauth/OAuth2Mappers.ts | 13 +++++- .../src/api/oauth/OAuth2RequestService.ts | 9 +++- .../oauth/tests/OAuth2ApplicationGet.test.ts | 31 +++++++++++++- fluxer_api/src/api/openapi/openapi.json | 24 ++++------- packages/constants/src/LimitConstants.ts | 2 + .../domains/admin/AdminApplicationSchemas.ts | 6 ++- .../src/domains/channel/ChannelSchemas.ts | 5 +-- .../src/domains/guild/GuildEmojiSchemas.ts | 5 ++- .../src/domains/guild/GuildRequestSchemas.ts | 10 +++-- .../schema/src/domains/oauth/OAuthSchemas.ts | 27 ++++++++---- .../src/domains/tests/ChannelSchemas.test.ts | 36 ++++++++++++++++ .../domains/tests/GuildEmojiSchemas.test.ts | 23 +++++++++++ .../domains/tests/GuildRequestSchemas.test.ts | 41 ++++++++++++++++++- .../src/domains/tests/OAuthSchemas.test.ts | 29 +++++++++++++ 15 files changed, 222 insertions(+), 41 deletions(-) create mode 100644 packages/schema/src/domains/tests/GuildEmojiSchemas.test.ts create mode 100644 packages/schema/src/domains/tests/OAuthSchemas.test.ts diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 0c1967744..52f9afbb5 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -9327,7 +9327,7 @@ "oauth2_redirect_uris": { "type": "array", "items": {"type": "string"}, - "maxItems": 100, + "maxItems": 10, "description": "Registered OAuth2 redirect URIs" }, "has_client_secret": { diff --git a/fluxer_api/src/api/oauth/OAuth2Mappers.ts b/fluxer_api/src/api/oauth/OAuth2Mappers.ts index 1e003b59d..ed0e0076c 100644 --- a/fluxer_api/src/api/oauth/OAuth2Mappers.ts +++ b/fluxer_api/src/api/oauth/OAuth2Mappers.ts @@ -1,11 +1,19 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {type UserAuthenticatorType, UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants'; import {stripBannerForUser} from '../infrastructure/AssetEntitlementUtils'; import type {Application} from '../models/Application'; import type {User} from '../models/User'; import {mapUserToPartialResponse} from '../user/UserMappers'; import type {ApplicationBotResponse, ApplicationResponse} from './OAuth2Types'; +function getActiveAuthenticatorTypes(user: User): Array { + return Array.from(user.authenticatorTypes ?? []).filter( + (type): type is UserAuthenticatorType => + type === UserAuthenticatorTypes.TOTP || type === UserAuthenticatorTypes.WEBAUTHN, + ); +} + export function mapBotUserToResponse( user: User, opts?: { @@ -13,6 +21,7 @@ export function mapBotUserToResponse( }, ): ApplicationBotResponse { const partial = mapUserToPartialResponse(user); + const authenticatorTypes = getActiveAuthenticatorTypes(user); return { id: partial.id, username: partial.username, @@ -21,8 +30,8 @@ export function mapBotUserToResponse( banner: stripBannerForUser(user), bio: user.bio ?? null, token: opts?.token, - mfa_enabled: (user.authenticatorTypes?.size ?? 0) > 0, - authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : [], + mfa_enabled: authenticatorTypes.length > 0, + authenticator_types: authenticatorTypes, flags: partial.flags, }; } diff --git a/fluxer_api/src/api/oauth/OAuth2RequestService.ts b/fluxer_api/src/api/oauth/OAuth2RequestService.ts index 8f9a2f917..3588ea461 100644 --- a/fluxer_api/src/api/oauth/OAuth2RequestService.ts +++ b/fluxer_api/src/api/oauth/OAuth2RequestService.ts @@ -47,7 +47,12 @@ import {verifyPassword} from '../utils/PasswordUtils'; import type {ApplicationService} from './ApplicationService'; import {ApplicationNotOwnedError} from './ApplicationService'; import type {BotAuthService} from './BotAuthService'; -import {mapApplicationToResponse, mapBotTokenResetResponse, mapBotUserToResponse} from './OAuth2Mappers'; +import { + mapApplicationToResponse, + mapBotProfileToResponse, + mapBotTokenResetResponse, + mapBotUserToResponse, +} from './OAuth2Mappers'; import {filterOAuth2Scopes} from './OAuth2ScopeUtils'; import {ACCESS_TOKEN_TTL_SECONDS, type OAuth2Service} from './OAuth2Service'; import type {IApplicationRepository} from './repositories/IApplicationRepository'; @@ -390,7 +395,7 @@ export class OAuth2RequestService { redirect_uris: Array.from(application.oauth2RedirectUris), scopes, bot_public: application.botIsPublic || isOwner, - bot: botUser ? mapBotUserToResponse(botUser) : null, + bot: botUser ? mapBotProfileToResponse(botUser) : null, current_user: requestingUser ? mapUserToPartialResponse(requestingUser) : null, }; } diff --git a/fluxer_api/src/api/oauth/tests/OAuth2ApplicationGet.test.ts b/fluxer_api/src/api/oauth/tests/OAuth2ApplicationGet.test.ts index 3578a26f4..1c72ffbb4 100644 --- a/fluxer_api/src/api/oauth/tests/OAuth2ApplicationGet.test.ts +++ b/fluxer_api/src/api/oauth/tests/OAuth2ApplicationGet.test.ts @@ -1,10 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants'; +import type {ApplicationPublicResponse} from '@fluxer/schema/src/domains/oauth/OAuthSchemas'; import {beforeEach, describe, expect, test} from 'vitest'; -import {createTestAccount} from '../../auth/tests/AuthTestUtils'; +import {createTestAccount, createTotpSecret, generateTotpCode} from '../../auth/tests/AuthTestUtils'; +import {createUserID} from '../../BrandedTypes'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {HTTP_STATUS} from '../../test/TestConstants'; import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; +import {UserRepository} from '../../user/repositories/UserRepository'; import {createOAuth2Application, createUniqueApplicationName, getOAuth2Application} from './OAuth2TestUtils'; describe('OAuth2 Application Get', () => { @@ -52,4 +56,29 @@ describe('OAuth2 Application Get', () => { test('requires authentication', async () => { await createBuilderWithoutAuth(harness).get('/oauth2/applications/123').expect(HTTP_STATUS.UNAUTHORIZED).execute(); }); + test('public bot representation omits the owner MFA mirror the owner view keeps', async () => { + const account = await createTestAccount(harness); + const createResult = await createOAuth2Application(harness, account.token, { + name: createUniqueApplicationName(), + }); + const secret = createTotpSecret(); + await createBuilder(harness, account.token) + .post('/users/@me/mfa/totp/enable') + .body({secret, code: generateTotpCode(secret), password: account.password}) + .expect(HTTP_STATUS.OK) + .execute(); + const botUser = await new UserRepository().findUnique(createUserID(BigInt(createResult.botUserId))); + expect(botUser?.authenticatorTypes.has(UserAuthenticatorTypes.TOTP)).toBe(true); + const ownerView = await getOAuth2Application(harness, account.token, createResult.application.id); + expect(ownerView.bot).toBeDefined(); + expect(ownerView.bot!.mfa_enabled).toBe(true); + expect(ownerView.bot!.authenticator_types).toEqual([UserAuthenticatorTypes.TOTP]); + const publicView = await createBuilderWithoutAuth(harness) + .get(`/oauth2/applications/${createResult.application.id}/public`) + .expect(HTTP_STATUS.OK) + .execute(); + expect(publicView.bot).toBeTruthy(); + expect('mfa_enabled' in publicView.bot!).toBe(false); + expect('authenticator_types' in publicView.bot!).toBe(false); + }); }); diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 58cfa0258..e9f0be3ee 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -26113,7 +26113,7 @@ "redirect_uris": { "type": "array", "items": {"type": "string"}, - "maxItems": 20, + "maxItems": 10, "description": "The registered redirect URIs for OAuth2" } }, @@ -27745,7 +27745,6 @@ "permission_overwrites": { "type": "array", "items": {"$ref": "#/components/schemas/ChannelOverwriteResponse"}, - "maxItems": 500, "description": "The permission overwrites for this channel" }, "recipients": { @@ -28608,7 +28607,7 @@ "properties": {"username": {"type": "string", "description": "The username of the recipient"}}, "required": ["username"] }, - "maxItems": 49, + "maxItems": 50, "description": "The recipients of the DM channel" } }, @@ -30246,7 +30245,7 @@ "tags": { "type": "array", "items": {"type": "string"}, - "maxItems": 100, + "maxItems": 10, "description": "Autocomplete/suggestion tags for the sticker" }, "animated": {"type": "boolean", "description": "Whether this sticker is animated"}, @@ -31585,8 +31584,8 @@ "roles": { "type": "array", "items": {"$ref": "#/components/schemas/SnowflakeType"}, - "maxItems": 100, - "description": "Array of role IDs to assign to the member (max 100)" + "maxItems": 250, + "description": "Array of role IDs to assign to the member (max 250)" }, "avatar": { "anyOf": [{"$ref": "#/components/schemas/Base64ImageType"}, {"type": "null"}], @@ -31738,7 +31737,7 @@ "tags": { "type": "array", "items": {"type": "string"}, - "maxItems": 100, + "maxItems": 10, "description": "Autocomplete/suggestion tags for the sticker" }, "animated": {"type": "boolean", "description": "Whether this sticker is animated"}, @@ -32164,7 +32163,7 @@ "redirect_uris": { "type": "array", "items": {"type": "string"}, - "maxItems": 20, + "maxItems": 10, "description": "The registered redirect URIs for OAuth2" }, "bot_public": {"type": "boolean", "description": "Whether the bot can be invited by anyone"}, @@ -32322,7 +32321,7 @@ "redirect_uris": { "type": "array", "items": {"type": "string"}, - "maxItems": 20, + "maxItems": 10, "description": "The registered redirect URIs for OAuth2" }, "scopes": { @@ -32357,13 +32356,6 @@ "description": "The bio or description of the bot" }, "token": {"type": "string", "description": "The bot token for authentication"}, - "mfa_enabled": {"type": "boolean", "description": "Whether the bot has MFA enabled"}, - "authenticator_types": { - "type": "array", - "items": {"$ref": "#/components/schemas/AuthenticatorType"}, - "maxItems": 10, - "description": "The types of authenticators enabled" - }, "flags": {"$ref": "#/components/schemas/BotFlags"} }, "required": ["id", "username", "discriminator", "bio", "flags"], diff --git a/packages/constants/src/LimitConstants.ts b/packages/constants/src/LimitConstants.ts index 3f74080bf..6714050fb 100644 --- a/packages/constants/src/LimitConstants.ts +++ b/packages/constants/src/LimitConstants.ts @@ -24,6 +24,7 @@ export const MAX_GUILD_EMOJIS_ANIMATED_MORE_EMOJI = MAX_GUILD_EMOJIS; export const MAX_GUILD_EMOJIS_STATIC_MORE_EMOJI = MAX_GUILD_EMOJIS; export const MAX_GUILD_STICKERS = 500; export const MAX_GUILD_STICKERS_MORE_STICKERS = MAX_GUILD_STICKERS; +export const MAX_GUILD_STICKER_TAGS = 10; export const MAX_GUILD_EXPRESSION_SLOTS_UNLIMITED = 999999; export const MAX_GUILD_INVITES = 1000; export const MAX_GUILD_MEMBERS = 1000000; @@ -32,6 +33,7 @@ export const MAX_INVITE_USES = 100; export const MAX_INVITE_AGE_SECONDS = 604800; export const MAX_GUILD_ROLES = 250; export const MAX_APPLICATIONS_PER_USER = 25; +export const MAX_APPLICATION_REDIRECT_URIS = 10; export const MAX_WEBHOOKS_PER_CHANNEL = 15; export const MAX_WEBHOOKS_PER_GUILD = 1000; export const MAX_MESSAGE_LENGTH_PREMIUM = 4000; diff --git a/packages/schema/src/domains/admin/AdminApplicationSchemas.ts b/packages/schema/src/domains/admin/AdminApplicationSchemas.ts index 25e6c0200..074e991ce 100644 --- a/packages/schema/src/domains/admin/AdminApplicationSchemas.ts +++ b/packages/schema/src/domains/admin/AdminApplicationSchemas.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {MAX_APPLICATION_REDIRECT_URIS} from '@fluxer/constants/src/LimitConstants'; import { createStringType, Int32Type, @@ -21,7 +22,10 @@ export const ApplicationAdminResponse = z.object({ bot_discriminator: z.string().nullable().describe('The discriminator of the bot user, if any'), bot_is_public: z.boolean().describe('Whether the bot is publicly joinable'), bot_require_code_grant: z.boolean().describe('Whether an OAuth2 code grant is required for this bot'), - oauth2_redirect_uris: z.array(z.string()).max(100).describe('Registered OAuth2 redirect URIs'), + oauth2_redirect_uris: z + .array(z.string()) + .max(MAX_APPLICATION_REDIRECT_URIS) + .describe('Registered OAuth2 redirect URIs'), has_client_secret: z.boolean().describe('Whether a hashed client secret is stored for this application'), has_bot_token: z.boolean().describe('Whether a hashed bot token is stored for this application'), bot_token_preview: z.string().nullable().describe('The preview (last few characters) of the bot token, if any'), diff --git a/packages/schema/src/domains/channel/ChannelSchemas.ts b/packages/schema/src/domains/channel/ChannelSchemas.ts index e57bbb040..475834b0d 100644 --- a/packages/schema/src/domains/channel/ChannelSchemas.ts +++ b/packages/schema/src/domains/channel/ChannelSchemas.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {CONTENT_WARNING_TEXT_MAX_LENGTH} from '@fluxer/constants/src/GuildConstants'; -import {MAX_GROUP_DM_OTHER_RECIPIENTS} from '@fluxer/constants/src/LimitConstants'; +import {MAX_GROUP_DM_OTHER_RECIPIENTS, MAX_GROUP_DM_RECIPIENTS} from '@fluxer/constants/src/LimitConstants'; import {type UserPartial, UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {ChannelOverwriteTypeSchema, ChannelTypeSchema} from '@fluxer/schema/src/primitives/ChannelValidators'; import {ContentWarningLevelSchema} from '@fluxer/schema/src/primitives/GuildValidators'; @@ -71,7 +71,6 @@ export const ChannelResponse = z.object({ .describe('The ISO 8601 timestamp of when the last pinned message was pinned'), permission_overwrites: z .array(ChannelOverwriteResponse) - .max(500) .optional() .describe('The permission overwrites for this channel'), recipients: z @@ -125,7 +124,7 @@ export const ChannelPartialResponse = z.object({ type: ChannelTypeSchema.describe('The type of the channel'), recipients: z .array(ChannelPartialRecipientResponse) - .max(MAX_GROUP_DM_OTHER_RECIPIENTS) + .max(MAX_GROUP_DM_RECIPIENTS) .optional() .describe('The recipients of the DM channel'), }); diff --git a/packages/schema/src/domains/guild/GuildEmojiSchemas.ts b/packages/schema/src/domains/guild/GuildEmojiSchemas.ts index 17ec12472..019b7913e 100644 --- a/packages/schema/src/domains/guild/GuildEmojiSchemas.ts +++ b/packages/schema/src/domains/guild/GuildEmojiSchemas.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {MAX_GUILD_STICKER_TAGS} from '@fluxer/constants/src/LimitConstants'; import {type UserPartial, UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {z} from 'zod'; @@ -27,7 +28,7 @@ export const GuildStickerResponse = z.object({ id: SnowflakeStringType.describe('The unique identifier for this sticker'), name: z.string().describe('The name of the sticker'), description: z.string().describe('The description of the sticker'), - tags: z.array(z.string()).max(100).describe('Autocomplete/suggestion tags for the sticker'), + tags: z.array(z.string()).max(MAX_GUILD_STICKER_TAGS).describe('Autocomplete/suggestion tags for the sticker'), animated: z.boolean().describe('Whether this sticker is animated'), nsfw: z.boolean().describe('Deprecated; always false. Retained for compatibility with older clients'), }); @@ -38,7 +39,7 @@ export const GuildStickerWithUserResponse = z.object({ id: SnowflakeStringType.describe('The unique identifier for this sticker'), name: z.string().describe('The name of the sticker'), description: z.string().describe('The description of the sticker'), - tags: z.array(z.string()).max(100).describe('Autocomplete/suggestion tags for the sticker'), + tags: z.array(z.string()).max(MAX_GUILD_STICKER_TAGS).describe('Autocomplete/suggestion tags for the sticker'), animated: z.boolean().describe('Whether this sticker is animated'), nsfw: z.boolean().describe('Deprecated; always false. Retained for compatibility with older clients'), user: z.lazy(() => UserPartialResponse).describe('The user who uploaded this sticker'), diff --git a/packages/schema/src/domains/guild/GuildRequestSchemas.ts b/packages/schema/src/domains/guild/GuildRequestSchemas.ts index 5875e3874..ad78dc7d3 100644 --- a/packages/schema/src/domains/guild/GuildRequestSchemas.ts +++ b/packages/schema/src/domains/guild/GuildRequestSchemas.ts @@ -10,6 +10,8 @@ import { import { AVATAR_MAX_SIZE, EMOJI_MAX_SIZE, + MAX_GUILD_ROLES, + MAX_GUILD_STICKER_TAGS, MAX_TEMP_BAN_DURATION_SECONDS, MIN_TEMP_BAN_DURATION_SECONDS, STICKER_MAX_SIZE, @@ -142,10 +144,10 @@ export const GuildMemberUpdateRequest = z.object({ .describe('The nickname to set for the member (1-32 characters)'), roles: z .array(SnowflakeType) - .max(100, 'Maximum 100 roles allowed') + .max(MAX_GUILD_ROLES, `Maximum ${MAX_GUILD_ROLES} roles allowed`) .optional() .transform((ids) => (ids ? new Set(ids) : undefined)) - .describe('Array of role IDs to assign to the member (max 100)'), + .describe(`Array of role IDs to assign to the member (max ${MAX_GUILD_ROLES})`), avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3))) .nullish() .describe('Base64-encoded image data for the member guild avatar'), @@ -241,10 +243,10 @@ export const GuildStickerCreateRequest = z.object({ tags: z .array(createStringType(1, 30)) .min(0) - .max(10) + .max(MAX_GUILD_STICKER_TAGS) .optional() .default([]) - .describe('Array of autocomplete/suggestion tags (max 10 tags, each 1-30 characters)'), + .describe(`Array of autocomplete/suggestion tags (max ${MAX_GUILD_STICKER_TAGS} tags, each 1-30 characters)`), image: createBase64StringType(1, Math.ceil(STICKER_MAX_SIZE * (4 / 3))).describe( 'Base64-encoded image data for the sticker', ), diff --git a/packages/schema/src/domains/oauth/OAuthSchemas.ts b/packages/schema/src/domains/oauth/OAuthSchemas.ts index 988235d04..71076180e 100644 --- a/packages/schema/src/domains/oauth/OAuthSchemas.ts +++ b/packages/schema/src/domains/oauth/OAuthSchemas.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {ApplicationFlags, BotFlags, BotFlagsDescriptions} from '@fluxer/constants/src/BotConstants'; -import {AVATAR_MAX_SIZE} from '@fluxer/constants/src/LimitConstants'; +import {AVATAR_MAX_SIZE, MAX_APPLICATION_REDIRECT_URIS} from '@fluxer/constants/src/LimitConstants'; import { PublicUserFlags, PublicUserFlagsDescriptions, @@ -176,7 +176,10 @@ const ApplicationBotResponse = z export const ApplicationResponse = z.object({ id: SnowflakeStringType.describe('The unique identifier of the application'), name: z.string().describe('The name of the application'), - redirect_uris: z.array(z.string()).max(20).describe('The registered redirect URIs for OAuth2'), + redirect_uris: z + .array(z.string()) + .max(MAX_APPLICATION_REDIRECT_URIS) + .describe('The registered redirect URIs for OAuth2'), bot_public: z.boolean().describe('Whether the bot can be invited by anyone'), bot_require_code_grant: z.boolean().describe('Whether the bot requires OAuth2 code grant'), client_secret: z.string().optional().describe('The client secret for OAuth2 authentication'), @@ -295,10 +298,16 @@ export const ApplicationPublicResponse = z.object({ name: z.string().describe('The name of the application'), icon: z.string().nullable().describe('The icon hash of the application'), description: z.string().nullable().describe('The description of the application'), - redirect_uris: z.array(z.string()).max(20).describe('The registered redirect URIs for OAuth2'), + redirect_uris: z + .array(z.string()) + .max(MAX_APPLICATION_REDIRECT_URIS) + .describe('The registered redirect URIs for OAuth2'), scopes: z.array(z.string()).max(50).describe('The available OAuth2 scopes'), bot_public: z.boolean().describe('Whether the bot can be invited by anyone'), - bot: ApplicationBotResponse.nullable().describe('The bot user associated with the application'), + bot: ApplicationBotResponse.omit({mfa_enabled: true, authenticator_types: true}) + .describe('Detailed bot user metadata') + .nullable() + .describe('The bot user associated with the application'), current_user: UserPartialResponse.nullable() .optional() .describe('Partial user data for the authenticated requester, when a session token is present'), @@ -316,7 +325,11 @@ export const ApplicationsMeResponse = z.object({ verify_key: z.string().describe('Compatibility placeholder for AppInfo clients until keys are persisted'), owner: UserPartialResponse.describe('The owner of the application'), bot: ApplicationBotResponse.optional().describe('The bot user associated with the application'), - redirect_uris: z.array(z.string()).max(20).optional().describe('The registered redirect URIs for OAuth2'), + redirect_uris: z + .array(z.string()) + .max(MAX_APPLICATION_REDIRECT_URIS) + .optional() + .describe('The registered redirect URIs for OAuth2'), }); export type ApplicationsMeResponse = z.infer; @@ -406,7 +419,7 @@ export const ApplicationCreateRequest = z.object({ name: createStringType(1, 100).describe('The name of the application'), redirect_uris: z .array(OAuth2RedirectURICreateType) - .max(10, 'Maximum of 10 redirect URIs allowed') + .max(MAX_APPLICATION_REDIRECT_URIS, `Maximum of ${MAX_APPLICATION_REDIRECT_URIS} redirect URIs allowed`) .optional() .nullable() .transform((value) => value ?? []) @@ -421,7 +434,7 @@ export const ApplicationUpdateRequest = z.object({ name: createStringType(1, 100).optional().describe('The name of the application'), redirect_uris: z .array(OAuth2RedirectURIUpdateType) - .max(10, 'Maximum of 10 redirect URIs allowed') + .max(MAX_APPLICATION_REDIRECT_URIS, `Maximum of ${MAX_APPLICATION_REDIRECT_URIS} redirect URIs allowed`) .optional() .nullable() .transform((value) => (value === undefined ? undefined : (value ?? []))) diff --git a/packages/schema/src/domains/tests/ChannelSchemas.test.ts b/packages/schema/src/domains/tests/ChannelSchemas.test.ts index de1a6073f..cf9794d57 100644 --- a/packages/schema/src/domains/tests/ChannelSchemas.test.ts +++ b/packages/schema/src/domains/tests/ChannelSchemas.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {MAX_GROUP_DM_RECIPIENTS} from '@fluxer/constants/src/LimitConstants'; import { ChannelOverwriteResponse, ChannelPartialResponse, @@ -105,6 +106,22 @@ describe('ChannelResponse', () => { expect(result.data.permission_overwrites).toHaveLength(2); } }); + it('accepts more permission_overwrites than the old response ceiling', () => { + const channel = { + ...validChannel, + permission_overwrites: Array.from({length: 501}, (_, index) => ({ + id: String(100000000000000000n + BigInt(index)), + type: 0, + allow: '8', + deny: '0', + })), + }; + const result = ChannelResponse.safeParse(channel); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.permission_overwrites).toHaveLength(501); + } + }); it('accepts voice channel properties', () => { const voiceChannel = { ...validChannel, @@ -221,4 +238,23 @@ describe('ChannelPartialResponse', () => { }); expect(result.success).toBe(true); }); + it('accepts a full group DM with every recipient listed', () => { + const result = ChannelPartialResponse.safeParse({ + id: '123456789012345678', + type: 3, + recipients: Array.from({length: MAX_GROUP_DM_RECIPIENTS}, (_, index) => ({username: `user${index}`})), + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.recipients).toHaveLength(MAX_GROUP_DM_RECIPIENTS); + } + }); + it('rejects more recipients than a group DM can hold', () => { + const result = ChannelPartialResponse.safeParse({ + id: '123456789012345678', + type: 3, + recipients: Array.from({length: MAX_GROUP_DM_RECIPIENTS + 1}, (_, index) => ({username: `user${index}`})), + }); + expect(result.success).toBe(false); + }); }); diff --git a/packages/schema/src/domains/tests/GuildEmojiSchemas.test.ts b/packages/schema/src/domains/tests/GuildEmojiSchemas.test.ts new file mode 100644 index 000000000..50e5ca1dd --- /dev/null +++ b/packages/schema/src/domains/tests/GuildEmojiSchemas.test.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {MAX_GUILD_STICKER_TAGS} from '@fluxer/constants/src/LimitConstants'; +import {GuildStickerResponse} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas'; +import {describe, expect, it} from 'vitest'; + +const buildSticker = (tagCount: number) => ({ + id: '1234567890123456789', + name: 'sticker', + description: 'A sticker', + tags: Array.from({length: tagCount}, (_, index) => `tag${index}`), + animated: false, + nsfw: false, +}); + +describe('GuildStickerResponse', () => { + it('accepts the maximum tag count', () => { + expect(GuildStickerResponse.safeParse(buildSticker(MAX_GUILD_STICKER_TAGS)).success).toBe(true); + }); + it('rejects one tag beyond the maximum', () => { + expect(GuildStickerResponse.safeParse(buildSticker(MAX_GUILD_STICKER_TAGS + 1)).success).toBe(false); + }); +}); diff --git a/packages/schema/src/domains/tests/GuildRequestSchemas.test.ts b/packages/schema/src/domains/tests/GuildRequestSchemas.test.ts index 47d5d5738..472f6e42d 100644 --- a/packages/schema/src/domains/tests/GuildRequestSchemas.test.ts +++ b/packages/schema/src/domains/tests/GuildRequestSchemas.test.ts @@ -1,7 +1,16 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {MAX_TEMP_BAN_DURATION_SECONDS, MIN_TEMP_BAN_DURATION_SECONDS} from '@fluxer/constants/src/LimitConstants'; -import {GuildBanCreateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas'; +import { + MAX_GUILD_ROLES, + MAX_GUILD_STICKER_TAGS, + MAX_TEMP_BAN_DURATION_SECONDS, + MIN_TEMP_BAN_DURATION_SECONDS, +} from '@fluxer/constants/src/LimitConstants'; +import { + GuildBanCreateRequest, + GuildMemberUpdateRequest, + GuildStickerCreateRequest, +} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas'; import {describe, expect, it} from 'vitest'; describe('GuildBanCreateRequest', () => { @@ -20,3 +29,31 @@ describe('GuildBanCreateRequest', () => { ); }); }); + +const buildRoleIds = (count: number) => + Array.from({length: count}, (_, index) => `${1234567890123456789n + BigInt(index)}`); + +describe('GuildMemberUpdateRequest', () => { + it('accepts every role a member can hold', () => { + expect(GuildMemberUpdateRequest.safeParse({roles: buildRoleIds(MAX_GUILD_ROLES)}).success).toBe(true); + }); + it('rejects one role beyond the guild role limit', () => { + expect(GuildMemberUpdateRequest.safeParse({roles: buildRoleIds(MAX_GUILD_ROLES + 1)}).success).toBe(false); + }); +}); + +describe('GuildStickerCreateRequest', () => { + const buildTags = (count: number) => Array.from({length: count}, (_, index) => `tag${index}`); + const image = `data:image/png;base64,${Buffer.from('sticker').toString('base64')}`; + it('accepts the maximum tag count', () => { + expect( + GuildStickerCreateRequest.safeParse({name: 'sticker', tags: buildTags(MAX_GUILD_STICKER_TAGS), image}).success, + ).toBe(true); + }); + it('rejects one tag beyond the maximum', () => { + expect( + GuildStickerCreateRequest.safeParse({name: 'sticker', tags: buildTags(MAX_GUILD_STICKER_TAGS + 1), image}) + .success, + ).toBe(false); + }); +}); diff --git a/packages/schema/src/domains/tests/OAuthSchemas.test.ts b/packages/schema/src/domains/tests/OAuthSchemas.test.ts new file mode 100644 index 000000000..47b45dc86 --- /dev/null +++ b/packages/schema/src/domains/tests/OAuthSchemas.test.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {MAX_APPLICATION_REDIRECT_URIS} from '@fluxer/constants/src/LimitConstants'; +import {ApplicationCreateRequest, ApplicationResponse} from '@fluxer/schema/src/domains/oauth/OAuthSchemas'; +import {describe, expect, it} from 'vitest'; + +const buildRedirectURIs = (count: number) => + Array.from({length: count}, (_, index) => `https://example.com/callback/${index}`); + +const buildApplicationResponse = (redirectURIs: Array) => ({ + id: '1234567890123456789', + name: 'Test Application', + redirect_uris: redirectURIs, + bot_public: true, + bot_require_code_grant: false, +}); + +describe('application redirect_uris bounds', () => { + it('accepts the maximum redirect URI count on both the request and the response', () => { + const uris = buildRedirectURIs(MAX_APPLICATION_REDIRECT_URIS); + expect(ApplicationCreateRequest.safeParse({name: 'Test Application', redirect_uris: uris}).success).toBe(true); + expect(ApplicationResponse.safeParse(buildApplicationResponse(uris)).success).toBe(true); + }); + it('rejects one redirect URI beyond the maximum on both the request and the response', () => { + const uris = buildRedirectURIs(MAX_APPLICATION_REDIRECT_URIS + 1); + expect(ApplicationCreateRequest.safeParse({name: 'Test Application', redirect_uris: uris}).success).toBe(false); + expect(ApplicationResponse.safeParse(buildApplicationResponse(uris)).success).toBe(false); + }); +});