diff --git a/migrations/20260116150327-add-user-id-to-file-versions.js b/migrations/20260116150327-add-user-id-to-file-versions.js new file mode 100644 index 000000000..8360c74f0 --- /dev/null +++ b/migrations/20260116150327-add-user-id-to-file-versions.js @@ -0,0 +1,23 @@ +'use strict'; + +const tableName = 'file_versions'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(tableName, 'user_id', { + type: Sequelize.STRING(36), + allowNull: false, + references: { + model: 'users', + key: 'uuid', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn(tableName, 'user_id'); + }, +}; diff --git a/migrations/20260116150328-add-index-file-versions-user-id-status.js b/migrations/20260116150328-add-index-file-versions-user-id-status.js new file mode 100644 index 000000000..baf5eacb2 --- /dev/null +++ b/migrations/20260116150328-add-index-file-versions-user-id-status.js @@ -0,0 +1,18 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + CREATE INDEX CONCURRENTLY file_versions_user_id_exists_idx + ON file_versions (user_id) + WHERE status = 'EXISTS'; + `); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + DROP INDEX CONCURRENTLY IF EXISTS file_versions_user_id_exists_idx; + `); + } +}; diff --git a/migrations/20260116154444-update-file-versioning-limits.js b/migrations/20260116154444-update-file-versioning-limits.js new file mode 100644 index 000000000..bf3a279c0 --- /dev/null +++ b/migrations/20260116154444-update-file-versioning-limits.js @@ -0,0 +1,94 @@ +'use strict'; + +const FILE_VERSION_LABELS = { + ENABLED: 'file-version-enabled', + MAX_SIZE: 'file-version-max-size', + RETENTION_DAYS: 'file-version-retention-days', + MAX_NUMBER: 'file-version-max-number', +}; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `UPDATE limits + SET value = 'false', updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label = :enabledLabel`, + { replacements: { enabledLabel: FILE_VERSION_LABELS.ENABLED } }, + ); + + await queryInterface.sequelize.query( + `UPDATE limits + SET value = '0', updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label IN (:counterLabels)`, + { + replacements: { + counterLabels: [ + FILE_VERSION_LABELS.MAX_SIZE, + FILE_VERSION_LABELS.RETENTION_DAYS, + FILE_VERSION_LABELS.MAX_NUMBER, + ], + }, + }, + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query( + `UPDATE limits + SET value = 'true', updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label = :enabledLabel`, + { replacements: { enabledLabel: FILE_VERSION_LABELS.ENABLED } }, + ); + + await queryInterface.sequelize.query( + `UPDATE limits + SET value = :maxSize, updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label = :label`, + { + replacements: { + maxSize: String(1 * 1024 * 1024), + label: FILE_VERSION_LABELS.MAX_SIZE, + }, + }, + ); + + await queryInterface.sequelize.query( + `UPDATE limits + SET value = '10', updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label = :label`, + { replacements: { label: FILE_VERSION_LABELS.RETENTION_DAYS } }, + ); + + await queryInterface.sequelize.query( + `UPDATE limits + SET value = '1', updated_at = NOW() + FROM tiers_limits tl, tiers t + WHERE tl.limit_id = limits.id + AND t.id = tl.tier_id + AND t.label = 'essential_individual' + AND limits.label = :label`, + { replacements: { label: FILE_VERSION_LABELS.MAX_NUMBER } }, + ); + }, +}; diff --git a/migrations/20260116154627-create-deleted-file-versions-table.js b/migrations/20260116154627-create-deleted-file-versions-table.js index 9617980f3..82c8ae4c4 100644 --- a/migrations/20260116154627-create-deleted-file-versions-table.js +++ b/migrations/20260116154627-create-deleted-file-versions-table.js @@ -10,21 +10,17 @@ module.exports = { allowNull: false, primaryKey: true, }, - file_id: { - type: Sequelize.UUID, - allowNull: true, - }, network_file_id: { type: Sequelize.STRING, - allowNull: true, + allowNull: false, }, size: { type: Sequelize.BIGINT, - allowNull: true, + allowNull: false, }, processed: { type: Sequelize.BOOLEAN, - allowNull: true, + allowNull: false, defaultValue: false, }, created_at: { @@ -41,7 +37,7 @@ module.exports = { }, enqueued: { type: Sequelize.BOOLEAN, - allowNull: true, + allowNull: false, defaultValue: false, }, enqueued_at: { diff --git a/migrations/20260116154629-deleted-file-versions-function.js b/migrations/20260116154629-deleted-file-versions-function.js index 3a6c25eed..c4a7a666f 100644 --- a/migrations/20260116154629-deleted-file-versions-function.js +++ b/migrations/20260116154629-deleted-file-versions-function.js @@ -9,11 +9,13 @@ module.exports = { LANGUAGE plpgsql AS $function$ BEGIN - IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' THEN + IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' + AND NEW.network_file_id IS NOT NULL + AND NEW.size IS NOT NULL + AND NEW.size > 0 THEN IF NOT EXISTS (SELECT 1 FROM deleted_file_versions WHERE file_version_id = NEW.id) THEN INSERT INTO deleted_file_versions ( file_version_id, - file_id, network_file_id, size, processed, @@ -22,7 +24,6 @@ module.exports = { updated_at ) VALUES ( NEW.id, - NEW.file_id, NEW.network_file_id, NEW.size, false, diff --git a/migrations/20260120204639-update-cli-access-limits.js b/migrations/20260120204639-update-cli-access-limits.js new file mode 100644 index 000000000..62452f0b7 --- /dev/null +++ b/migrations/20260120204639-update-cli-access-limits.js @@ -0,0 +1,140 @@ +'use strict'; + +const CLI_LIMIT_LABEL = 'cli-access'; + +const CLI_DISABLED_TIER_LABELS = [ + // Legacy tiers + '200gb_individual', + '2tb_individual', + '5tb_individual', + '10tb_individual', + // Current tiers (except ultimate_individual and pro_business) + 'essential_individual', + 'premium_individual', + 'standard_business', + 'essential_lifetime_individual', + 'premium_lifetime_individual', + 'ultimate_lifetime_individual', +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const [limits] = await queryInterface.sequelize.query( + `SELECT id, value FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: CLI_LIMIT_LABEL }, transaction }, + ); + + if (limits.length === 0) { + throw new Error('CLI access limits not found in database'); + } + + const disabledLimitId = limits.find((l) => l.value === 'false')?.id; + const enabledLimitId = limits.find((l) => l.value === 'true')?.id; + + if (!disabledLimitId || !enabledLimitId) { + throw new Error('CLI access enabled or disabled limit not found'); + } + + const [tiersToDisable] = await queryInterface.sequelize.query( + `SELECT id, label FROM tiers WHERE label IN (:labels)`, + { + replacements: { labels: CLI_DISABLED_TIER_LABELS }, + transaction, + }, + ); + + if (tiersToDisable.length === 0) { + console.log( + 'No tiers found to restrict, skipping CLI access restriction', + ); + await transaction.commit(); + return; + } + + const tierIds = tiersToDisable.map((t) => t.id); + + console.log( + `Restricting CLI access for ${tiersToDisable.length} tiers: ${tiersToDisable.map((t) => t.label).join(', ')}`, + ); + + await queryInterface.sequelize.query( + `UPDATE tiers_limits + SET limit_id = :disabledLimitId, updated_at = NOW() + WHERE tier_id IN (:tierIds) + AND limit_id = :enabledLimitId`, + { + replacements: { + disabledLimitId, + enabledLimitId, + tierIds, + }, + transaction, + }, + ); + + await transaction.commit(); + + console.log('Successfully restricted CLI access for specified tiers'); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const [limits] = await queryInterface.sequelize.query( + `SELECT id, value FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: CLI_LIMIT_LABEL }, transaction }, + ); + + const disabledLimitId = limits.find((l) => l.value === 'false')?.id; + const enabledLimitId = limits.find((l) => l.value === 'true')?.id; + + if (!disabledLimitId || !enabledLimitId) { + throw new Error('CLI access limits not found'); + } + + const [tiers] = await queryInterface.sequelize.query( + `SELECT id FROM tiers WHERE label IN (:labels)`, + { + replacements: { labels: CLI_DISABLED_TIER_LABELS }, + transaction, + }, + ); + + if (tiers.length === 0) { + await transaction.commit(); + return; + } + + const tierIds = tiers.map((t) => t.id); + + await queryInterface.sequelize.query( + `UPDATE tiers_limits + SET limit_id = :enabledLimitId, updated_at = NOW() + WHERE tier_id IN (:tierIds) + AND limit_id = :disabledLimitId`, + { + replacements: { + disabledLimitId, + enabledLimitId, + tierIds, + }, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/migrations/20260121062641-update-cli-access-ultimate-lifetime.js b/migrations/20260121062641-update-cli-access-ultimate-lifetime.js new file mode 100644 index 000000000..dae440cfe --- /dev/null +++ b/migrations/20260121062641-update-cli-access-ultimate-lifetime.js @@ -0,0 +1,105 @@ +'use strict'; + +const CLI_LIMIT_LABEL = 'cli-access'; +const TIER_LABEL = 'ultimate_lifetime_individual'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const [limits] = await queryInterface.sequelize.query( + `SELECT id, value FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: CLI_LIMIT_LABEL }, transaction }, + ); + + if (limits.length === 0) { + throw new Error('CLI access limits not found in database'); + } + + const enabledLimitId = limits.find((l) => l.value === 'true')?.id; + const disabledLimitId = limits.find((l) => l.value === 'false')?.id; + + if (!enabledLimitId || !disabledLimitId) { + throw new Error('CLI access enabled or disabled limit not found'); + } + + const [tiers] = await queryInterface.sequelize.query( + `SELECT id FROM tiers WHERE label = :label`, + { replacements: { label: TIER_LABEL }, transaction }, + ); + + if (tiers.length === 0) { + console.log(`Tier ${TIER_LABEL} not found, skipping`); + await transaction.commit(); + return; + } + + const tierId = tiers[0].id; + + await queryInterface.sequelize.query( + `UPDATE tiers_limits + SET limit_id = :enabledLimitId, updated_at = NOW() + WHERE tier_id = :tierId + AND limit_id = :disabledLimitId`, + { + replacements: { enabledLimitId, disabledLimitId, tierId }, + transaction, + }, + ); + + await transaction.commit(); + console.log(`Successfully granted CLI access to ${TIER_LABEL}`); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const [limits] = await queryInterface.sequelize.query( + `SELECT id, value FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: CLI_LIMIT_LABEL }, transaction }, + ); + + const enabledLimitId = limits.find((l) => l.value === 'true')?.id; + const disabledLimitId = limits.find((l) => l.value === 'false')?.id; + + if (!enabledLimitId || !disabledLimitId) { + throw new Error('CLI access limits not found'); + } + + const [tiers] = await queryInterface.sequelize.query( + `SELECT id FROM tiers WHERE label = :label`, + { replacements: { label: TIER_LABEL }, transaction }, + ); + + if (tiers.length === 0) { + await transaction.commit(); + return; + } + + const tierId = tiers[0].id; + + await queryInterface.sequelize.query( + `UPDATE tiers_limits + SET limit_id = :disabledLimitId, updated_at = NOW() + WHERE tier_id = :tierId + AND limit_id = :enabledLimitId`, + { + replacements: { enabledLimitId, disabledLimitId, tierId }, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/migrations/20260121124206-add-rclone-access-limit.js b/migrations/20260121124206-add-rclone-access-limit.js new file mode 100644 index 000000000..6ee1f549e --- /dev/null +++ b/migrations/20260121124206-add-rclone-access-limit.js @@ -0,0 +1,108 @@ +'use strict'; + +const { v4 } = require('uuid'); + +const LIMIT_LABEL = 'rclone-access'; + +const RCLONE_ENABLED_TIER_LABELS = [ + 'ultimate_individual', + 'ultimate_lifetime_individual', + 'pro_business', +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const disabledLimitId = v4(); + const enabledLimitId = v4(); + + await queryInterface.bulkInsert( + 'limits', + [ + { + id: disabledLimitId, + label: LIMIT_LABEL, + name: 'Rclone access disabled', + type: 'boolean', + value: 'false', + created_at: new Date(), + updated_at: new Date(), + }, + { + id: enabledLimitId, + label: LIMIT_LABEL, + name: 'Rclone access enabled', + type: 'boolean', + value: 'true', + created_at: new Date(), + updated_at: new Date(), + }, + ], + { transaction }, + ); + + const [tiers] = await queryInterface.sequelize.query( + `SELECT id, label FROM tiers`, + { transaction }, + ); + + if (tiers.length === 0) { + throw new Error('No tiers found in database'); + } + + const tierLimitRelations = tiers.map((tier) => { + const isEnabled = RCLONE_ENABLED_TIER_LABELS.includes(tier.label); + + return { + id: v4(), + tier_id: tier.id, + limit_id: isEnabled ? enabledLimitId : disabledLimitId, + created_at: new Date(), + updated_at: new Date(), + }; + }); + + await queryInterface.bulkInsert('tiers_limits', tierLimitRelations, { + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const [limits] = await queryInterface.sequelize.query( + `SELECT id FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: LIMIT_LABEL }, transaction }, + ); + + const limitIds = limits.map((l) => l.id); + + if (limitIds.length > 0) { + await queryInterface.sequelize.query( + `DELETE FROM tiers_limits WHERE limit_id IN (:limitIds)`, + { replacements: { limitIds }, transaction }, + ); + } + + await queryInterface.sequelize.query( + `DELETE FROM limits WHERE label = :limitLabel`, + { replacements: { limitLabel: LIMIT_LABEL }, transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/migrations/20260122025914-cleanup-orphaned-devices.js b/migrations/20260122025914-cleanup-orphaned-devices.js new file mode 100644 index 000000000..9e27f1cb1 --- /dev/null +++ b/migrations/20260122025914-cleanup-orphaned-devices.js @@ -0,0 +1,50 @@ +'use strict'; + +const MAX_ATTEMPTS = 10; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, _Sequelize) { + console.info( + 'Starting migration: cleaning up devices with deleted folders', + ); + + let attempts = 0; + let success = false; + + while (!success && attempts < MAX_ATTEMPTS) { + try { + const [results] = await queryInterface.sequelize.query(` + DELETE FROM devices + WHERE id IN ( + SELECT d.id + FROM devices d + INNER JOIN folders f ON d.folder_uuid = f.uuid + WHERE f.deleted = true or f.removed = true + ) + RETURNING id; + `); + + console.info( + `Migration completed. Deleted ${results.length} orphaned devices.`, + ); + success = true; + } catch (err) { + attempts++; + console.error( + `[ERROR]: Error during deletion (attempt ${attempts}/${MAX_ATTEMPTS}): ${err.message}`, + ); + + if (attempts >= MAX_ATTEMPTS) { + console.error( + '[ERROR]: Maximum retry attempts reached, exiting migration.', + ); + throw err; + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } + }, + + async down() {}, +}; diff --git a/migrations/20260122102300-fix-file-deleted-trigger-null-file-id.js b/migrations/20260122102300-fix-file-deleted-trigger-null-file-id.js new file mode 100644 index 000000000..602e0190d --- /dev/null +++ b/migrations/20260122102300-fix-file-deleted-trigger-null-file-id.js @@ -0,0 +1,44 @@ +'use strict'; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE OR REPLACE FUNCTION public.file_deleted_trigger() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' AND OLD.file_id IS NOT NULL THEN + IF NOT EXISTS (SELECT 1 FROM deleted_files WHERE file_id = OLD.uuid) THEN + INSERT INTO deleted_files (file_id, network_file_id, processed, created_at, updated_at, processed_at) + VALUES (OLD.uuid, OLD.file_id, false, NOW(), NOW(), NULL); + END IF; + END IF; + RETURN NEW; + END; + $function$ + ; + `); + }, + + async down(queryInterface) { + // Revert to the previous implementation (without checking NEW.file_id). + await queryInterface.sequelize.query(` + CREATE OR REPLACE FUNCTION public.file_deleted_trigger() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + IF OLD.status != 'DELETED' AND NEW.status = 'DELETED' THEN + IF NOT EXISTS (SELECT 1 FROM deleted_files WHERE file_id = OLD.uuid) THEN + INSERT INTO deleted_files (file_id, network_file_id, processed, created_at, updated_at, processed_at) + VALUES (OLD.uuid, OLD.file_id, false, NOW(), NOW(), NULL); + END IF; + END IF; + RETURN NEW; + END; + $function$ + ; + `); + }, +}; \ No newline at end of file diff --git a/package.json b/package.json index 5f148715d..9e11b3739 100644 --- a/package.json +++ b/package.json @@ -80,8 +80,6 @@ "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", "prettysize": "^2.0.0", - "prom-client": "^15.0.0", - "prometheus-api-metrics": "^4.0.0", "qrcode": "^1.4.4", "redis": "^5.8.2", "reflect-metadata": "^0.2.2", @@ -97,14 +95,14 @@ "uuid": "^11.1.0" }, "devDependencies": { - "@golevelup/ts-jest": "^0.6.2", + "@golevelup/ts-jest": "^1.2.0", "@internxt/eslint-config-internxt": "^1.0.9", "@nestjs/schematics": "^11.0.7", "@nestjs/testing": "^11.1.6", "@types/chance": "^1.1.6", "@types/crypto-js": "^4.2.1", "@types/express": "^5.0.1", - "@types/jest": "29.5.14", + "@types/jest": "^30.0.0", "@types/jsonwebtoken": "9.0.2", "@types/multer": "^1.4.13", "@types/multer-s3": "^3.0.3", @@ -121,13 +119,13 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.1", "husky": "^8.0.3", - "jest": "29.7.0", + "jest": "^30.2.0", "lint-staged": "^15.1.0", "prettier": "^3.5.3", "sequelize-cli": "^6.6.3", "source-map-support": "^0.5.21", "supertest": "^7.1.4", - "ts-jest": "29.3.2", + "ts-jest": "^29.4.6", "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", diff --git a/src/app.module.ts b/src/app.module.ts index a884f3764..416a076a2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -30,9 +30,9 @@ import { HttpGlobalExceptionFilter } from './common/http-global-exception-filter import { JobsModule } from './modules/jobs/jobs.module'; import { v4 } from 'uuid'; import { getClientIdFromHeaders } from './common/decorators/client.decorator'; -import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'; import { CustomThrottlerGuard } from './guards/throttler.guard'; import { AuthGuard } from './modules/auth/auth.guard'; +import { CustomThrottlerModule } from './guards/throttler.module'; @Module({ imports: [ @@ -125,21 +125,7 @@ import { AuthGuard } from './modules/auth/auth.guard'; }), }), EventEmitterModule.forRoot({ wildcard: true, delimiter: '.' }), - ThrottlerModule.forRootAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => { - return ({ - throttlers: [ - { - ttl: seconds(config.get('users.rateLimit.default.ttl')), - limit: config.get('users.rateLimit.default.limit') - } - ], - storage: new ThrottlerStorageRedisService(config.get('cache.redisConnectionString')) - }) - }, - }), + CustomThrottlerModule, JobsModule, NotificationModule, NotificationsModule, @@ -170,12 +156,12 @@ import { AuthGuard } from './modules/auth/auth.guard'; }, { provide: APP_GUARD, - useClass: AuthGuard + useClass: AuthGuard, }, { provide: APP_GUARD, - useClass: CustomThrottlerGuard - } + useClass: CustomThrottlerGuard, + }, ], }) export class AppModule {} diff --git a/src/common/constants.ts b/src/common/constants.ts index 12f672a5b..60267fc3a 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,3 +1,12 @@ +import { ClientEnum } from './enums/platform.enum'; + export enum PlatformName { CLI = 'cli', + RCLONE = 'rclone', } + +export const ClientToPlatformMap: Partial> = { + [ClientEnum.Cli]: PlatformName.CLI, + [ClientEnum.CliLegacy]: PlatformName.CLI, + [ClientEnum.Rclone]: PlatformName.RCLONE, +}; diff --git a/src/common/enums/platform.enum.ts b/src/common/enums/platform.enum.ts index bf1001554..539d818d1 100644 --- a/src/common/enums/platform.enum.ts +++ b/src/common/enums/platform.enum.ts @@ -2,4 +2,7 @@ export enum ClientEnum { Web = 'drive-web', Mobile = 'drive-mobile', Desktop = 'drive-desktop', + Cli = 'internxt-cli', + CliLegacy = '@internxt/cli', + Rclone = 'rclone-adapter', } diff --git a/src/common/http-global-exception-filter.exception.ts b/src/common/http-global-exception-filter.exception.ts index 2e7df2924..4d1fcf206 100644 --- a/src/common/http-global-exception-filter.exception.ts +++ b/src/common/http-global-exception-filter.exception.ts @@ -39,6 +39,20 @@ export class HttpGlobalExceptionFilter extends BaseExceptionFilter { const requestId = request.id; + if (this.isDatabaseConnectionError(exception)) { + this.logDatabaseConnectionError(exception, request); + + return httpAdapter.reply( + response, + { + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + message: 'Service temporarily unavailable', + requestId, + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + this.logUnexpectedError(exception, request); return httpAdapter.reply( @@ -83,6 +97,34 @@ export class HttpGlobalExceptionFilter extends BaseExceptionFilter { ); } + private isDatabaseConnectionError(exception: any): boolean { + const connectionErrorNames = [ + 'SequelizeConnectionAcquireTimeoutError', + 'SequelizeConnectionError', + 'SequelizeConnectionRefusedError', + 'SequelizeConnectionTimedOutError', + ]; + + return connectionErrorNames.includes(exception?.name); + } + + private logDatabaseConnectionError(exception: any, request) { + const errorResponse = { + name: exception.name, + path: request.url, + errorType: 'DATABASE_CONNECTION_ERROR', + method: request.method, + user: { + uuid: request?.user?.uuid, + }, + error: { + message: exception.message, + }, + }; + + this.logger.error(errorResponse, 'DATABASE_CONNECTION_ERROR'); + } + logUnexpectedError(exception: any, request) { let errorSubtype = ''; if (exception instanceof SequelizeError) { diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 48ffbe4a6..97d301414 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -146,21 +146,21 @@ export default () => ({ rateLimit: { default: { ttl: process.env.RATE_LIMIT_DEFAULT_TTL, - limit: process.env.RATE_LIMIT_DEFAULT_LIMIT + limit: process.env.RATE_LIMIT_DEFAULT_LIMIT, }, anonymous: { ttl: process.env.RATE_LIMIT_ANON_TTL, - limit: process.env.RATE_LIMIT_ANON_LIMIT + limit: process.env.RATE_LIMIT_ANON_LIMIT, }, free: { ttl: process.env.RATE_LIMIT_FREE_TTL, - limit: process.env.RATE_LIMIT_FREE_LIMIT + limit: process.env.RATE_LIMIT_FREE_LIMIT, }, paid: { ttl: process.env.RATE_LIMIT_PAID_TTL, - limit: process.env.RATE_LIMIT_PAID_LIMIT - } - } + limit: process.env.RATE_LIMIT_PAID_LIMIT, + }, + }, }, jitsi: { appId: process.env.JITSI_APP_ID, diff --git a/src/guards/custom-endpoint-throttle.decorator.ts b/src/guards/custom-endpoint-throttle.decorator.ts new file mode 100644 index 000000000..a34aba68e --- /dev/null +++ b/src/guards/custom-endpoint-throttle.decorator.ts @@ -0,0 +1,20 @@ +import { SetMetadata } from '@nestjs/common'; + +export const CUSTOM_ENDPOINT_THROTTLE_KEY = 'customEndpointThrottle'; + +export interface CustomThrottleOptions { + ttl: number; // seconds + limit: number; +} + +/** + * You can use two different shapes: + * - single policy: { ttl, limit } + * - named policies: { short: { ttl, limit }, long: { ttl, limit } } + */ +export type CustomThrottleArg = + | CustomThrottleOptions + | Record; + +export const CustomThrottle = (opts: CustomThrottleArg) => + SetMetadata(CUSTOM_ENDPOINT_THROTTLE_KEY, opts); diff --git a/src/guards/custom-endpoint-throttle.guard.spec.ts b/src/guards/custom-endpoint-throttle.guard.spec.ts new file mode 100644 index 000000000..e1d0f6b15 --- /dev/null +++ b/src/guards/custom-endpoint-throttle.guard.spec.ts @@ -0,0 +1,162 @@ +import * as tsjest from '@golevelup/ts-jest'; +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { CustomEndpointThrottleGuard } from './custom-endpoint-throttle.guard'; +import { CacheManagerService } from '../modules/cache-manager/cache-manager.service'; +import { ThrottlerException } from '@nestjs/throttler'; + +describe('CustomThrottleGuard', () => { + let guard: CustomEndpointThrottleGuard; + let reflector: Reflector; + let cacheService: jest.Mocked; + + beforeEach(() => { + reflector = tsjest.createMock(); + cacheService = tsjest.createMock(); + cacheService.increment = jest.fn(); + guard = new CustomEndpointThrottleGuard(reflector, cacheService as any); + }); + + describe('canActivate', () => { + it('When reflector returns no metadata then the guard checks are skipped', async () => { + (reflector.get as jest.Mock).mockReturnValue(undefined); + const context = tsjest.createMock(); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(cacheService.increment).not.toHaveBeenCalled(); + }); + + describe('Applying a single policy', () => { + const route = '/login'; + + it('When under limit then it allows the request to pass', async () => { + const policy = { ttl: 60, limit: 5 }; + (reflector.get as jest.Mock).mockReturnValue(policy); + + const request: any = { + route: { path: route }, + user: { uuid: 'user-1' }, + ip: '1.2.3.4', + }; + (cacheService.increment as jest.Mock).mockResolvedValue({ + totalHits: 1, + timeToExpire: 5000, + }); + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(cacheService.increment).toHaveBeenCalledWith( + `${request.route.path}:policy0:cet:uid:${request.user.uuid}`, + 60, + ); + }); + + it('When over the limit then the request is throttled', async () => { + const policy = { ttl: 60, limit: 1 }; + (reflector.get as jest.Mock).mockReturnValue(policy); + + const request: any = { + route: { path: route }, + user: { uuid: 'user-2' }, + ip: '2.2.2.2', + }; + (cacheService.increment as jest.Mock).mockResolvedValue({ + totalHits: 2, + timeToExpire: 1000, + }); + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf( + ThrottlerException, + ); + expect(cacheService.increment).toHaveBeenCalledWith( + `${request.route.path}:policy0:cet:uid:${request.user.uuid}`, + 60, + ); + }); + }); + + describe('Applying multiple policies', () => { + const route = '/login'; + + it('When under limits then it allows the request to pass', async () => { + const named = { + short: { ttl: 60, limit: 5 }, + long: { ttl: 3600, limit: 30 }, + }; + (reflector.get as jest.Mock).mockReturnValue(named); + const request: any = { + route: { path: route }, + user: null, + ip: '9.9.9.9', + }; + + (cacheService.increment as jest.Mock) + .mockResolvedValueOnce({ + totalHits: named.short.limit - 1, + timeToExpire: 100, + }) + .mockResolvedValueOnce({ + totalHits: named.long.limit - 1, + timeToExpire: 1000, + }); + + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + expect(cacheService.increment).toHaveBeenCalledWith( + `${request.route.path}:short:cet:ip:${request.ip}`, + named.short.ttl, + ); + expect(cacheService.increment).toHaveBeenCalledWith( + `${request.route.path}:long:cet:ip:${request.ip}`, + named.long.ttl, + ); + }); + + it('when over the limit then the request is throttled', async () => { + const named = { + short: { ttl: 60, limit: 1 }, + long: { ttl: 3600, limit: 30 }, + }; + (reflector.get as jest.Mock).mockReturnValue(named); + const request: any = { + route: { path: route }, + user: null, + ip: '11.11.11.11', + }; + + const shortOverTheLimit = named.short.limit + 1; + (cacheService.increment as jest.Mock) + .mockResolvedValueOnce({ + totalHits: shortOverTheLimit, + timeToExpire: 10, + }) + .mockResolvedValueOnce({ + totalHits: named.long.limit - 1, + timeToExpire: 1000, + }); + + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf( + ThrottlerException, + ); + expect(cacheService.increment).toHaveBeenCalledWith( + `${request.route.path}:short:cet:ip:${request.ip}`, + 60, + ); + }); + }); + }); +}); diff --git a/src/guards/custom-endpoint-throttle.guard.ts b/src/guards/custom-endpoint-throttle.guard.ts new file mode 100644 index 000000000..7eef13166 --- /dev/null +++ b/src/guards/custom-endpoint-throttle.guard.ts @@ -0,0 +1,77 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Inject, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ThrottlerException } from '@nestjs/throttler'; +import { CacheManagerService } from '../modules/cache-manager/cache-manager.service'; +import { + CUSTOM_ENDPOINT_THROTTLE_KEY, + CustomThrottleOptions, +} from './custom-endpoint-throttle.decorator'; + +@Injectable() +export class CustomEndpointThrottleGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly cacheService: CacheManagerService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const raw = this.reflector.get( + CUSTOM_ENDPOINT_THROTTLE_KEY, + context.getHandler(), + ); + + // If no custom throttle metadata, do not block (this guard should be applied + // only where needed). Returning true lets other guards run. + if (!raw) return true; + + const policies: Array = []; + + if ( + typeof raw === 'object' && + (raw as any).ttl === undefined && + (raw as any).limit === undefined + ) { + // named policies object: { short: { ttl, limit }, long: { ttl, limit } } + const entries = Object.entries(raw) as [string, CustomThrottleOptions][]; + for (const [name, val] of entries) { + policies.push({ ...(val as CustomThrottleOptions), key: name }); + } + } else { + policies.push({ + ...(raw as CustomThrottleOptions), + key: (raw as any).key ?? 'policy0', + }); + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + const identifierBase = user?.uuid + ? `cet:uid:${user.uuid}` + : `cet:ip:${request.ip}`; + const route = request.route?.path ?? request.originalUrl ?? 'unknown'; + + // Apply all policies. If any policy is violated, throw. + for (let i = 0; i < policies.length; i++) { + const p = policies[i]; + // Prefer an explicit stable key from the policy so the identity + // remains the same even if the array order changes. Fallback to + // index-based id when no key provided. + const policyId = p.key ? String(p.key) : `policy${i}`; + const sanitizedRoute = String(route).replace(/\s+/g, '_'); + const sanitizedPolicyId = policyId.replace(/\s+/g, '_'); + const key = `${sanitizedRoute}:${sanitizedPolicyId}:${identifierBase}`; + const record = await this.cacheService.increment(key, p.ttl); + if (record.totalHits > p.limit) { + throw new ThrottlerException(); + } + } + + return true; + } +} diff --git a/src/guards/throttler.guard.ts b/src/guards/throttler.guard.ts index b8f9b4159..e95496bb5 100644 --- a/src/guards/throttler.guard.ts +++ b/src/guards/throttler.guard.ts @@ -1,10 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ThrottlerGuard as BaseThrottlerGuard, ThrottlerModuleOptions, ThrottlerRequest, ThrottlerStorageService } from '@nestjs/throttler'; -import { ConfigService } from '@nestjs/config'; -import { Reflector } from '@nestjs/core'; -import type { Request } from 'express'; -import { User } from '../modules/user/user.domain'; - +import { ThrottlerGuard as BaseThrottlerGuard } from '@nestjs/throttler'; @Injectable() export class ThrottlerGuard extends BaseThrottlerGuard { protected async getTracker(req: Record): Promise { @@ -15,70 +10,9 @@ export class ThrottlerGuard extends BaseThrottlerGuard { } @Injectable() -export class CustomThrottlerGuard extends BaseThrottlerGuard { - constructor( - options: ThrottlerModuleOptions, - storageService: ThrottlerStorageService, - reflector: Reflector, - private readonly config: ConfigService, - ) { - super(options, storageService, reflector); - } - - protected async getTracker(req: Record): Promise { - const user = req.user; - if (user && (user.id || user.uuid)) { - return `user:${user.id ?? user.uuid}`; - } - const auth = req.headers['authorization'] as string | undefined; - if (auth) return `token:${auth.slice(0, 200)}`; - const forwarded = (req.headers['x-forwarded-for'] as string) || ''; - const ip = forwarded ? forwarded.split(',')[0].trim() : req.ip || req.socket?.remoteAddress || 'unknown'; - return `ip:${ip}`; - } - - protected async handleRequest(requestProps: ThrottlerRequest): Promise { - const { context } = requestProps; - - const handlerContext = context.getHandler(); - const classContext = context.getClass(); - - const isPublic = this.reflector.get('isPublic', handlerContext); - const disableGlobalAuth = this.reflector.getAllAndOverride( - 'disableGlobalAuth', - [handlerContext, classContext], - ); - - const req = context.switchToHttp().getRequest(); - - if (isPublic || disableGlobalAuth || !req.user) { - const anonymousLimit = this.config.get('users.rateLimit.anonymous.limit'); - const anonymousTTL = this.config.get('users.rateLimit.anonymous.ttl'); - - requestProps.ttl = anonymousTTL; - requestProps.limit = anonymousLimit; - - return super.handleRequest(requestProps); - } - - const user = req.user as User; - const isFreeUser = user.tierId === this.config.get('users.freeTierId'); - - if (isFreeUser) { - const freeLimit = this.config.get('users.rateLimit.free.limit'); - const freeTTL = this.config.get('users.rateLimit.free.ttl'); - - requestProps.ttl = freeTTL; - requestProps.limit = freeLimit; - - return super.handleRequest(requestProps); - } - - const paidLimit = this.config.get('users.rateLimit.paid.limit'); - const paidTTL = this.config.get('users.rateLimit.paid.ttl'); - requestProps.ttl = paidTTL; - requestProps.limit = paidLimit; - - return super.handleRequest(requestProps); +export class CustomThrottlerGuard extends ThrottlerGuard { + protected async getTracker(req: any): Promise { + const userId = req.user?.uuid; + return userId ? `rl:${userId}` : `rl:${req.ip}`; } -} \ No newline at end of file +} diff --git a/src/guards/throttler.interceptor.spec.ts b/src/guards/throttler.interceptor.spec.ts new file mode 100644 index 000000000..47bcc3e6e --- /dev/null +++ b/src/guards/throttler.interceptor.spec.ts @@ -0,0 +1,110 @@ +import * as tsjest from '@golevelup/ts-jest'; +import { CustomThrottlerInterceptor } from './throttler.interceptor'; +import { CacheManagerService } from '../modules/cache-manager/cache-manager.service'; +import { ConfigService } from '@nestjs/config'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { ThrottlerException } from '@nestjs/throttler'; +import { of } from 'rxjs'; + +describe('CustomThrottlerInterceptor', () => { + let interceptor: CustomThrottlerInterceptor; + let configService: jest.Mocked; + let cacheService: jest.Mocked; + let reflector: Reflector; + + beforeEach(() => { + configService = tsjest.createMock(); + cacheService = tsjest.createMock(); + cacheService.increment = jest.fn(); + reflector = tsjest.createMock(); + + interceptor = new CustomThrottlerInterceptor( + configService as any, + cacheService as any, + reflector, + ); + }); + + describe('intercept', () => { + it('When handler or class has custom throttle metadata then bypasses global throttling', async () => { + (reflector.get as jest.Mock).mockReturnValue(true); + const context = tsjest.createMock(); + const next: Partial = { handle: jest.fn(() => of('ok')) }; + + await interceptor.intercept(context, next as CallHandler); + + expect( + (next.handle as jest.Mock).mock.calls.length, + ).toBeGreaterThanOrEqual(1); + expect(cacheService.increment).not.toHaveBeenCalled(); + }); + + it('When anonymous request under limit then increments by ip and allows', async () => { + (reflector.get as jest.Mock).mockReturnValue(undefined); + configService.get = jest.fn((key: string) => { + if (key === 'users.rateLimit.anonymous.ttl') return 30; + if (key === 'users.rateLimit.anonymous.limit') return 10; + return undefined; + }) as any; + + const request: any = { ip: '10.0.0.1', user: null }; + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + (cacheService.increment as jest.Mock).mockResolvedValue({ + totalHits: 1, + timeToExpire: 1000, + }); + const next: Partial = { handle: jest.fn(() => of('ok')) }; + + await interceptor.intercept(context, next as CallHandler); + + expect(cacheService.increment).toHaveBeenCalledWith( + `rl:${request.ip}`, + 30, + ); + expect( + (next.handle as jest.Mock).mock.calls.length, + ).toBeGreaterThanOrEqual(1); + }); + + it('When authenticated free-tier user exceeds limit then the request is throttled', async () => { + (reflector.get as jest.Mock).mockReturnValue(undefined); + const freeTierId = 'free-tier'; + configService.get = jest.fn((key: string) => { + switch (key) { + case 'users.freeTierId': + return freeTierId; + case 'users.rateLimit.free.ttl': + return 20; + case 'users.rateLimit.free.limit': + return 1; + default: + return undefined; + } + }) as any; + + const request: any = { + ip: '1.1.1.1', + user: { uuid: 'u123', tierId: freeTierId }, + }; + const context = tsjest.createMock(); + (context as any).switchToHttp = () => ({ getRequest: () => request }); + + (cacheService.increment as jest.Mock).mockResolvedValue({ + totalHits: 5, + timeToExpire: 100, + }); + const next: Partial = { handle: jest.fn(() => of('ok')) }; + + await expect( + interceptor.intercept(context, next as CallHandler), + ).rejects.toBeInstanceOf(ThrottlerException); + expect(cacheService.increment).toHaveBeenCalledWith( + `rl:${request.user.uuid}`, + 20, + ); + }); + }); +}); diff --git a/src/guards/throttler.interceptor.ts b/src/guards/throttler.interceptor.ts new file mode 100644 index 000000000..f043dfc6f --- /dev/null +++ b/src/guards/throttler.interceptor.ts @@ -0,0 +1,75 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CacheManagerService } from '../modules/cache-manager/cache-manager.service'; +import { Observable } from 'rxjs'; +import { ThrottlerException } from '@nestjs/throttler'; +import { User } from 'src/modules/user/user.domain'; +import { Reflector } from '@nestjs/core'; +import { CUSTOM_ENDPOINT_THROTTLE_KEY } from './custom-endpoint-throttle.decorator'; + +@Injectable() +export class CustomThrottlerInterceptor implements NestInterceptor { + constructor( + private readonly configService: ConfigService, + private readonly cacheService: CacheManagerService, + private readonly reflector: Reflector, + ) {} + + private getRateLimit(user?: User): { ttl: number; limit: number } { + if (!user) { + return { + ttl: this.configService.get('users.rateLimit.anonymous.ttl'), + limit: this.configService.get( + 'users.rateLimit.anonymous.limit', + ), + }; + } + if (user.tierId === this.configService.get('users.freeTierId')) { + return { + ttl: this.configService.get('users.rateLimit.free.ttl'), + limit: this.configService.get('users.rateLimit.free.limit'), + }; + } + return { + ttl: this.configService.get('users.rateLimit.paid.ttl'), + limit: this.configService.get('users.rateLimit.paid.limit'), + }; + } + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const request = context.switchToHttp().getRequest(); + + // Interceptors run before guards, so we must check metadata and + // bypass the global interceptor when custom throttle is present. + const hasCustom = + this.reflector.get(CUSTOM_ENDPOINT_THROTTLE_KEY, context.getHandler()) || + this.reflector.get(CUSTOM_ENDPOINT_THROTTLE_KEY, context.getClass()); + + if (hasCustom) { + return next.handle(); + } + const user = request.user as User | null; + let key = `rl:${request.ip}`; + if (user && user.uuid) { + key = `rl:${user.uuid}`; + } + + const { ttl, limit } = this.getRateLimit(user); + + const record = await this.cacheService.increment(key, ttl); + + if (record.totalHits > limit) { + throw new ThrottlerException(); + } + + return next.handle(); + } +} diff --git a/src/guards/throttler.module.ts b/src/guards/throttler.module.ts new file mode 100644 index 000000000..344a35cc5 --- /dev/null +++ b/src/guards/throttler.module.ts @@ -0,0 +1,31 @@ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { seconds, ThrottlerModule } from '@nestjs/throttler'; +import { CacheManagerService } from '../modules/cache-manager/cache-manager.service'; +import { Module } from '@nestjs/common'; +import { CustomThrottlerInterceptor } from './throttler.interceptor'; +import { CacheManagerModule } from '../modules/cache-manager/cache-manager.module'; + +@Module({ + imports: [ + CacheManagerModule, + ThrottlerModule.forRootAsync({ + imports: [ConfigModule, CacheManagerModule], + inject: [CacheManagerService, ConfigService], + useFactory: ( + customStorage: CacheManagerService, + configService: ConfigService, + ) => ({ + storage: customStorage, + throttlers: [ + { + ttl: seconds(configService.get('users.rateLimit.default.ttl')), + limit: configService.get('users.rateLimit.default.limit'), + }, + ], + }), + }), + ], + providers: [CustomThrottlerInterceptor], + exports: [CustomThrottlerInterceptor], +}) +export class CustomThrottlerModule {} diff --git a/src/lib/newrelic.interceptor.ts b/src/lib/newrelic.interceptor.ts index 3f668fb6e..20518e7fb 100644 --- a/src/lib/newrelic.interceptor.ts +++ b/src/lib/newrelic.interceptor.ts @@ -1,5 +1,10 @@ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; -const newrelic = require('newrelic') +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +const newrelic = require('newrelic'); /** * Only for the headers, the instrumentation is not done directly here @@ -15,18 +20,24 @@ export class NewRelicInterceptor implements NestInterceptor { if (rawClient) { newrelic.addCustomAttribute( 'internxtClient', - String(Array.isArray(rawClient) ? rawClient[0] : rawClient).slice(0, 50), + String(Array.isArray(rawClient) ? rawClient[0] : rawClient).slice( + 0, + 50, + ), ); } if (rawVersion) { newrelic.addCustomAttribute( 'internxtVersion', - String(Array.isArray(rawVersion) ? rawVersion[0] : rawVersion).slice(0, 15), + String(Array.isArray(rawVersion) ? rawVersion[0] : rawVersion).slice( + 0, + 15, + ), ); } - console.log(rawClient, rawVersion) + console.log(rawClient, rawVersion); return next.handle(); } diff --git a/src/main.ts b/src/main.ts index 9af9f2674..7e837af2b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,10 +3,9 @@ import dotenv from 'dotenv'; dotenv.config({ path: `.env.${process.env.NODE_ENV}` }); import { ValidationPipe } from '@nestjs/common'; -import { NestFactory, Reflector } from '@nestjs/core'; +import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { Logger } from 'nestjs-pino'; -import apiMetrics from 'prometheus-api-metrics'; import helmet from 'helmet'; import { DocumentBuilder, @@ -18,6 +17,7 @@ import configuration from './config/configuration'; import { TransformInterceptor } from './lib/transform.interceptor'; import { RequestLoggerInterceptor } from './middlewares/requests-logger.interceptor'; import { NewRelicInterceptor } from './lib/newrelic.interceptor'; +import { CustomThrottlerInterceptor } from './guards/throttler.interceptor'; const config = configuration(); const APP_PORT = config.port || 3000; @@ -57,9 +57,9 @@ async function bootstrap() { app.useGlobalPipes(new ValidationPipe({ transform: true })); app.useGlobalInterceptors(new TransformInterceptor()); app.useGlobalInterceptors(new NewRelicInterceptor()); + app.useGlobalInterceptors(app.get(CustomThrottlerInterceptor)); app.use(helmet()); - app.use(apiMetrics()); if (!config.isProduction) { app.useGlobalInterceptors(new RequestLoggerInterceptor()); diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts index 34e9966e5..e961ca9d8 100644 --- a/src/modules/auth/auth.controller.spec.ts +++ b/src/modules/auth/auth.controller.spec.ts @@ -23,6 +23,7 @@ import { Test } from '@nestjs/testing'; import { FeatureLimitService } from '../feature-limit/feature-limit.service'; import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; import { PlatformName } from '../../common/constants'; +import { ClientEnum } from '../../common/enums/platform.enum'; describe('AuthController', () => { let authController: AuthController; @@ -401,7 +402,7 @@ describe('AuthController', () => { loginAccessDto.publicKey = 'publicKey'; loginAccessDto.revocateKey = 'revocateKey'; - it('When valid CLI login access details are provided and user can access platform, then it should return successfully', async () => { + it('When valid CLI login with new header and user can access platform, then it should return successfully', async () => { const eccKey = newKeyServer({ ...loginAccessDto }); const mockUser = newUser({ attributes: { tierId: v4() } }); const mockLoginResult = { @@ -423,7 +424,56 @@ describe('AuthController', () => { .spyOn(featureLimitService, 'canUserAccessPlatform') .mockResolvedValueOnce(true); - const result = await authController.cliLoginAccess(loginAccessDto); + const result = await authController.cliLoginAccess( + loginAccessDto, + ClientEnum.Cli, + ); + + expect(userUseCases.loginAccess).toHaveBeenCalledWith({ + ...loginAccessDto, + keys: { + ecc: { + publicKey: eccKey.publicKey, + privateKey: eccKey.privateKey, + revocationKey: eccKey.revocationKey, + }, + kyber: null, + }, + platform: PlatformName.CLI, + }); + expect(featureLimitService.canUserAccessPlatform).toHaveBeenCalledWith( + PlatformName.CLI, + mockUser.uuid, + ); + expect(result).toEqual(mockLoginResult); + }); + + it('When valid CLI login with legacy header and user can access platform, then it should return successfully', async () => { + const eccKey = newKeyServer({ ...loginAccessDto }); + const mockUser = newUser({ attributes: { tierId: v4() } }); + const mockLoginResult = { + user: mockUser, + token: 'jwt-token', + newToken: 'new-jwt-token', + } as any; + + jest.spyOn(keyServerUseCases, 'parseKeysInput').mockReturnValueOnce({ + ecc: eccKey.toJSON(), + kyber: null, + }); + + jest + .spyOn(userUseCases, 'loginAccess') + .mockResolvedValueOnce(mockLoginResult); + + jest + .spyOn(featureLimitService, 'canUserAccessPlatform') + .mockResolvedValueOnce(true); + + const result = await authController.cliLoginAccess( + loginAccessDto, + ClientEnum.CliLegacy, + ); expect(userUseCases.loginAccess).toHaveBeenCalledWith({ ...loginAccessDto, @@ -444,6 +494,58 @@ describe('AuthController', () => { expect(result).toEqual(mockLoginResult); }); + it('When valid Rclone login and user can access platform, then it should return successfully', async () => { + const eccKey = newKeyServer({ ...loginAccessDto }); + const mockUser = newUser({ attributes: { tierId: v4() } }); + const mockLoginResult = { + user: mockUser, + token: 'jwt-token', + newToken: 'new-jwt-token', + } as any; + + jest.spyOn(keyServerUseCases, 'parseKeysInput').mockReturnValueOnce({ + ecc: eccKey.toJSON(), + kyber: null, + }); + + jest + .spyOn(userUseCases, 'loginAccess') + .mockResolvedValueOnce(mockLoginResult); + + jest + .spyOn(featureLimitService, 'canUserAccessPlatform') + .mockResolvedValueOnce(true); + + const result = await authController.cliLoginAccess( + loginAccessDto, + ClientEnum.Rclone, + ); + + expect(userUseCases.loginAccess).toHaveBeenCalledWith({ + ...loginAccessDto, + keys: { + ecc: { + publicKey: eccKey.publicKey, + privateKey: eccKey.privateKey, + revocationKey: eccKey.revocationKey, + }, + kyber: null, + }, + platform: PlatformName.RCLONE, + }); + expect(featureLimitService.canUserAccessPlatform).toHaveBeenCalledWith( + PlatformName.RCLONE, + mockUser.uuid, + ); + expect(result).toEqual(mockLoginResult); + }); + + it('When unknown client header is provided, then it should throw BadRequestException', async () => { + await expect( + authController.cliLoginAccess(loginAccessDto, 'unknown-client'), + ).rejects.toThrow(BadRequestException); + }); + it('When user cannot access CLI platform, then it should throw PaymentRequiredException', async () => { const eccKey = newKeyServer({ ...loginAccessDto }); const mockUser = newUser({ attributes: { tierId: 'free_id' } }); @@ -467,7 +569,7 @@ describe('AuthController', () => { .mockResolvedValueOnce(false); await expect( - authController.cliLoginAccess(loginAccessDto), + authController.cliLoginAccess(loginAccessDto, ClientEnum.Cli), ).rejects.toThrow(PaymentRequiredException); expect(featureLimitService.canUserAccessPlatform).toHaveBeenCalledWith( @@ -476,6 +578,38 @@ describe('AuthController', () => { ); }); + it('When user cannot access Rclone platform, then it should throw PaymentRequiredException', async () => { + const eccKey = newKeyServer({ ...loginAccessDto }); + const mockUser = newUser({ attributes: { tierId: 'free_id' } }); + const mockLoginResult = { + success: true, + user: mockUser, + token: 'jwt-token', + } as any; + + jest.spyOn(keyServerUseCases, 'parseKeysInput').mockReturnValueOnce({ + ecc: eccKey.toJSON(), + kyber: null, + }); + + jest + .spyOn(userUseCases, 'loginAccess') + .mockResolvedValueOnce(mockLoginResult); + + jest + .spyOn(featureLimitService, 'canUserAccessPlatform') + .mockResolvedValueOnce(false); + + await expect( + authController.cliLoginAccess(loginAccessDto, ClientEnum.Rclone), + ).rejects.toThrow(PaymentRequiredException); + + expect(featureLimitService.canUserAccessPlatform).toHaveBeenCalledWith( + PlatformName.RCLONE, + mockUser.uuid, + ); + }); + it('When CLI login access includes both ecc and kyber keys, then it should parse and pass them correctly', async () => { const eccKey = newKeyServer(); const kyberKey = newKeyServer({ @@ -511,7 +645,7 @@ describe('AuthController', () => { .spyOn(featureLimitService, 'canUserAccessPlatform') .mockResolvedValueOnce(true); - await authController.cliLoginAccess(inputWithKyberKeys); + await authController.cliLoginAccess(inputWithKyberKeys, ClientEnum.Cli); expect(userUseCases.loginAccess).toHaveBeenCalledWith({ ...inputWithKyberKeys, diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 944d2380e..6a91e22a9 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -44,9 +44,11 @@ import { LoginAccessResponseDto } from './dto/responses/login-access-response.dt import { LoginResponseDto } from './dto/responses/login-response.dto'; import { JwtToken } from './decorators/get-jwt.decorator'; import { AuthUsecases } from './auth.usecase'; -import { PlatformName } from '../../common/constants'; +import { ClientToPlatformMap } from '../../common/constants'; import { FeatureLimitService } from '../feature-limit/feature-limit.service'; import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; +import { Client } from '../../common/decorators/client.decorator'; +import { ClientEnum } from '../../common/enums/platform.enum'; @ApiTags('Auth') @Controller('auth') @@ -268,22 +270,31 @@ export class AuthController { @Post('/cli/login/access') @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: 'CLI platform login access', + summary: 'CLI/Rclone platform login access', }) @ApiOkResponse({ - description: 'CLI user successfully accessed their account', + description: 'User successfully accessed their account via CLI or Rclone', type: LoginAccessResponseDto, }) @ApiPaymentRequiredResponse({ - description: 'This user current tier does not allow CLI access', + description: 'This user current tier does not allow CLI/Rclone access', }) @Public() async cliLoginAccess( @Body() body: LoginAccessDto, + @Client() client: string, ): Promise { + const platform = ClientToPlatformMap[client as ClientEnum]; + + if (!platform) { + throw new BadRequestException( + `Invalid client header '${client}' for this endpoint`, + ); + } + this.logger.log( - { email: body.email, category: 'CLI-LOGIN-ACCESS' }, - 'Attempting CLI login', + { email: body.email, category: 'CLI-LOGIN-ACCESS', client, platform }, + 'Attempting platform login', ); try { const { ecc, kyber } = this.keyServerUseCases.parseKeysInput(body.keys, { @@ -295,29 +306,29 @@ export class AuthController { const result = await this.userUseCases.loginAccess({ ...body, keys: { kyber, ecc }, - platform: PlatformName.CLI, + platform, }); const canUserAccess = await this.featureLimitService.canUserAccessPlatform( - PlatformName.CLI, + platform, result.user.uuid, ); if (!canUserAccess) throw new PaymentRequiredException( - 'CLI access not allowed for this user tier', + `${platform} access not allowed for this user tier`, ); this.logger.log( - { email: body.email, category: 'CLI-LOGIN-ACCESS' }, - 'Successful CLI login', + { email: body.email, category: 'CLI-LOGIN-ACCESS', platform }, + 'Successful platform login', ); return result; } catch (error) { this.logger.error( - { email: body.email, category: 'CLI-LOGIN-ACCESS', error }, - 'Failed CLI login attempt', + { email: body.email, category: 'CLI-LOGIN-ACCESS', client, error }, + 'Failed platform login attempt', ); throw error; } diff --git a/src/modules/auth/jwt.strategy.ts b/src/modules/auth/jwt.strategy.ts index 865b97908..c02510a0c 100644 --- a/src/modules/auth/jwt.strategy.ts +++ b/src/modules/auth/jwt.strategy.ts @@ -2,6 +2,7 @@ import { Inject, UnauthorizedException, Logger, + HttpException, Injectable, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -93,6 +94,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, strategyId) { throw err; } + if (!(err instanceof HttpException)) { + throw err; + } + Logger.error( `[AUTH/MIDDLEWARE] ERROR validating authorization ${ err.message diff --git a/src/modules/backups/backup.usecase.spec.ts b/src/modules/backups/backup.usecase.spec.ts index 919fe48de..bf84686b6 100644 --- a/src/modules/backups/backup.usecase.spec.ts +++ b/src/modules/backups/backup.usecase.spec.ts @@ -119,6 +119,7 @@ describe('BackupUseCase', () => { it('When backups are activated, then it should return all devices as folders', async () => { const mockFolder = newFolder(); + jest .spyOn(folderUseCases, 'getFoldersByUserId') .mockResolvedValue([mockFolder]); @@ -128,8 +129,14 @@ describe('BackupUseCase', () => { const result = await backupUseCase.getDevicesAsFolder(userMocked); + const mockFolderResponse = { + //TODO: temporary hotfix remove after mac newer version is released + ...newBackupFolder(mockFolder), + plain_name: mockFolder.plainName, + }; + result.forEach((folder) => { - expect(folder).toEqual({ ...newBackupFolder(mockFolder) }); + expect(folder).toEqual(mockFolderResponse); }); }); }); diff --git a/src/modules/backups/backup.usecase.ts b/src/modules/backups/backup.usecase.ts index c1f6d6fc8..2e69877da 100644 --- a/src/modules/backups/backup.usecase.ts +++ b/src/modules/backups/backup.usecase.ts @@ -142,10 +142,17 @@ export class BackupUseCase { }); return Promise.all( - folders.map(async (folder) => ({ - ...(await this.addFolderAsDeviceProperties(user, folder)), - plainName: this.cryptoService.decryptName(folder.name, folder.bucket), - })), + folders.map(async (folder) => { + const plainName = + folder.plainName ?? + this.cryptoService.decryptName(folder.name, folder.bucket); + + return { + ...(await this.addFolderAsDeviceProperties(user, folder)), + plainName, + plain_name: plainName, //TODO: temporary hotfix remove after mac newer version is released + }; + }), ); } diff --git a/src/modules/cache-manager/cache-manager.module.ts b/src/modules/cache-manager/cache-manager.module.ts index 0c4a22c81..1ba9a8f57 100644 --- a/src/modules/cache-manager/cache-manager.module.ts +++ b/src/modules/cache-manager/cache-manager.module.ts @@ -26,9 +26,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; // Error propagation should be stopped by adding event listener redisStore.on('error', (err) => - logger.error( - `Error on redis client: ${err}, url: ${redisUrl}`, - ), + logger.error(`Error on redis client: ${err}, url: ${redisUrl}`), ); return { diff --git a/src/modules/cache-manager/cache-manager.service.spec.ts b/src/modules/cache-manager/cache-manager.service.spec.ts index c2fc92df3..ab2800c33 100644 --- a/src/modules/cache-manager/cache-manager.service.spec.ts +++ b/src/modules/cache-manager/cache-manager.service.spec.ts @@ -307,4 +307,133 @@ describe('CacheManagerService', () => { }); }); }); + + describe('getRecord', () => { + const key = 'throttle:some:key'; + + it('When entry exists and not expired then returns a record succesfully', async () => { + const now = 1_600_000_000_000; + const expirationTTL = 5_000; + const expiresAt = now + expirationTTL; + const entry = { hits: 3, expiresAt }; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(entry as any); + + const result = await cacheManagerService.getRecord(key); + + expect(cacheManager.get).toHaveBeenCalledWith(key); + expect(result).toEqual({ + totalHits: 3, + timeToExpire: expirationTTL, + isBlocked: false, + timeToBlockExpire: 0, + }); + }); + + it('When entry exists but expired then returns record with time to expire set to 0', async () => { + const now = 1_600_000_010_000; + const expiresAt = now - 1_000; + const entry = { hits: 2, expiresAt }; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(entry as any); + + const result = await cacheManagerService.getRecord(key); + + expect(result).toEqual({ + totalHits: 2, + timeToExpire: 0, + isBlocked: false, + timeToBlockExpire: 0, + }); + }); + + it('When cache returns null then returns undefined', async () => { + jest.spyOn(cacheManager, 'get').mockResolvedValue(null); + + const result = await cacheManagerService.getRecord(key); + + expect(result).toBeUndefined(); + }); + }); + + describe('increment', () => { + const key = 'throttle:some:key'; + + it('When there is no existing entry then it sets hits=1 and ttl equals requested ttl (ms)', async () => { + const now = 1_600_000_020_000; + const ttlSeconds = 60; + const ttlMs = ttlSeconds * 1000; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(null); + const setSpy = jest + .spyOn(cacheManager, 'set') + .mockResolvedValue(undefined as any); + + const result = await cacheManagerService.increment(key, ttlSeconds); + + expect(cacheManager.get).toHaveBeenCalledWith(key); + expect(setSpy).toHaveBeenCalledWith( + key, + { hits: 1, expiresAt: now + ttlMs }, + ttlMs, + ); + expect(result.totalHits).toBe(1); + expect(result.timeToExpire).toBe(ttlMs); + }); + + it('When existing entry present and not expired then it increments hits and preserves the expiration time', async () => { + const now = 1_600_000_030_000; + const expiresAt = now + 3_000; + const existing = { hits: 2, expiresAt }; + const ttlSeconds = 10; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(existing as any); + const setSpy = jest + .spyOn(cacheManager, 'set') + .mockResolvedValue(undefined as any); + + const result = await cacheManagerService.increment(key, ttlSeconds); + const expectedNewHits = existing.hits + 1; + + expect(setSpy).toHaveBeenCalledWith( + key, + { hits: expectedNewHits, expiresAt }, + expiresAt - now, + ); + expect(result.totalHits).toBe(expectedNewHits); + expect(result.timeToExpire).toBe(expiresAt - now); + }); + + it('When existing entry expired then it sets hits=1 and ttl equals requested ttl (ms)', async () => { + const now = 1_600_000_040_000; + const expiresAt = now - 500; + const existing = { hits: 5, expiresAt }; + const ttlSeconds = 30; + + jest.spyOn(Date, 'now').mockReturnValue(now); + jest.spyOn(cacheManager, 'get').mockResolvedValue(existing as any); + const setSpy = jest + .spyOn(cacheManager, 'set') + .mockResolvedValue(undefined as any); + + const result = await cacheManagerService.increment(key, ttlSeconds); + const expectedNewHits = 1; + const expectedTimeToExpire = ttlSeconds * 1000; + + expect(setSpy).toHaveBeenCalledWith( + key, + { + hits: expectedNewHits, + expiresAt: now + expectedTimeToExpire, + }, + expectedTimeToExpire, + ); + expect(result.totalHits).toBe(expectedNewHits); + expect(result.timeToExpire).toBe(expectedTimeToExpire); + }); + }); }); diff --git a/src/modules/cache-manager/cache-manager.service.ts b/src/modules/cache-manager/cache-manager.service.ts index d50622ca1..21acc70e2 100644 --- a/src/modules/cache-manager/cache-manager.service.ts +++ b/src/modules/cache-manager/cache-manager.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; +import { ThrottlerStorageRecord } from '@nestjs/throttler/dist/throttler-storage-record.interface'; @Injectable() export class CacheManagerService { @@ -8,7 +9,7 @@ export class CacheManagerService { private readonly LIMIT_KEY_PREFIX = 'limit:'; private readonly JWT_KEY_PREFIX = 'jwt:'; private readonly AVATAR_KEY_PREFIX = 'avatar:'; - private readonly TTL_10_MINUTES = 10000 * 60; + private readonly TTL_10_MINUTES = 10 * 60 * 1000; private readonly TTL_24_HOURS = 24 * 60 * 60 * 1000; constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} @@ -100,4 +101,58 @@ export class CacheManagerService { async deleteUserAvatar(userUuid: string) { return this.cacheManager.del(`${this.AVATAR_KEY_PREFIX}${userUuid}`); } + + async getRecord(key: string): Promise { + const entry = await this.cacheManager.get<{ + hits: number; + expiresAt: number; + }>(key); + + if (entry && typeof entry.hits === 'number') { + const now = Date.now(); + const timeToExpire = entry.expiresAt > now ? entry.expiresAt - now : 0; + const record: ThrottlerStorageRecord = { + totalHits: entry.hits, + timeToExpire, + isBlocked: false, + timeToBlockExpire: 0, + }; + return record; + } + return undefined; + } + + async increment( + key: string, + ttlSeconds: number, + ): Promise { + const ttlMs = ttlSeconds * 1000; + const now = Date.now(); + + const existing = await this.cacheManager.get<{ + hits: number; + expiresAt: number; + }>(key); + + let hits = 1; + let expiresAt = now + ttlMs; + const existingAndNotExpired = existing && existing.expiresAt > now; + + if (existingAndNotExpired) { + hits = existing.hits + 1; + expiresAt = existing.expiresAt; + } + + const remainingTtl = Math.max(0, expiresAt - now); + + await this.cacheManager.set(key, { hits, expiresAt }, remainingTtl); + + const record: ThrottlerStorageRecord = { + totalHits: hits, + timeToExpire: remainingTtl, + isBlocked: false, + timeToBlockExpire: 0, + }; + return record; + } } diff --git a/src/modules/feature-limit/feature-limit.service.ts b/src/modules/feature-limit/feature-limit.service.ts index a6972f0dc..8d1ee3064 100644 --- a/src/modules/feature-limit/feature-limit.service.ts +++ b/src/modules/feature-limit/feature-limit.service.ts @@ -23,6 +23,7 @@ export class FeatureLimitService { ): Promise { const platformLimitLabelsMap: Record = { [PlatformName.CLI]: LimitLabels.CliAccess, + [PlatformName.RCLONE]: LimitLabels.RcloneAccess, }; const limitLabel = platformLimitLabelsMap[platform]; diff --git a/src/modules/feature-limit/limits.enum.ts b/src/modules/feature-limit/limits.enum.ts index e5468a58f..4c74c7aa2 100644 --- a/src/modules/feature-limit/limits.enum.ts +++ b/src/modules/feature-limit/limits.enum.ts @@ -8,6 +8,7 @@ export enum LimitLabels { FileVersionRetentionDays = 'file-version-retention-days', FileVersionMaxNumber = 'file-version-max-number', MaxZeroSizeFiles = 'max-zero-size-files', + RcloneAccess = 'rclone-access', } export enum LimitTypes { diff --git a/src/modules/file/actions/create-file-version.action.spec.ts b/src/modules/file/actions/create-file-version.action.spec.ts new file mode 100644 index 000000000..b07b51130 --- /dev/null +++ b/src/modules/file/actions/create-file-version.action.spec.ts @@ -0,0 +1,378 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CreateFileVersionAction } from './create-file-version.action'; +import { SequelizeFileRepository } from '../file.repository'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { FeatureLimitService } from '../../feature-limit/feature-limit.service'; +import { newFile, newUser } from '../../../../test/fixtures'; +import { FileVersion, FileVersionStatus } from '../file-version.domain'; +import dayjs from 'dayjs'; + +describe('CreateFileVersionAction', () => { + let action: CreateFileVersionAction; + let fileRepository: SequelizeFileRepository; + let fileVersionRepository: SequelizeFileVersionRepository; + let featureLimitService: FeatureLimitService; + + const userMocked = newUser(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CreateFileVersionAction, + { + provide: SequelizeFileRepository, + useValue: { + updateByUuidAndUserId: jest.fn(), + }, + }, + { + provide: SequelizeFileVersionRepository, + useValue: { + create: jest.fn(), + findAllByFileId: jest.fn(), + updateStatusBatch: jest.fn(), + }, + }, + { + provide: FeatureLimitService, + useValue: { + getFileVersioningLimits: jest.fn(), + }, + }, + ], + }).compile(); + + action = module.get(CreateFileVersionAction); + fileRepository = module.get( + SequelizeFileRepository, + ); + fileVersionRepository = module.get( + SequelizeFileVersionRepository, + ); + featureLimitService = module.get(FeatureLimitService); + }); + + describe('When creating file version without modificationTime', () => { + it('then should create version and update file', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + bucket: 'test-bucket', + type: 'pdf', + size: BigInt(100), + }, + }); + + const newFileId = 'new-file-id'; + const newSize = BigInt(200); + + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue({ + enabled: true, + maxFileSize: 1000000, + retentionDays: 15, + maxVersions: 10, + }); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue([]); + jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + + await action.execute(userMocked, mockFile, newFileId, newSize); + + expect(fileVersionRepository.create).toHaveBeenCalledWith({ + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: mockFile.fileId, + size: mockFile.size, + status: FileVersionStatus.EXISTS, + }); + + expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + expect.objectContaining({ + fileId: newFileId, + size: newSize, + updatedAt: expect.any(Date), + }), + ); + }); + }); + + describe('When creating file version with modificationTime', () => { + it('then should create version and update file with modificationTime', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + bucket: 'test-bucket', + type: 'pdf', + size: BigInt(100), + }, + }); + + const newFileId = 'new-file-id'; + const newSize = BigInt(200); + const modificationTime = new Date(); + + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue({ + enabled: true, + maxFileSize: 1000000, + retentionDays: 15, + maxVersions: 10, + }); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue([]); + jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + + await action.execute( + userMocked, + mockFile, + newFileId, + newSize, + modificationTime, + ); + + expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + expect.objectContaining({ + fileId: newFileId, + size: newSize, + modificationTime, + updatedAt: expect.any(Date), + }), + ); + }); + }); + + describe('When retention policy needs to delete old versions', () => { + it('then should delete versions older than retention period', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + bucket: 'test-bucket', + type: 'pdf', + size: BigInt(100), + }, + }); + + const retentionDays = 15; + const oldVersionDate = dayjs() + .subtract(retentionDays + 1, 'day') + .toDate(); + + const existingVersions = [ + FileVersion.build({ + id: 'version-1', + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: 'network-1', + size: BigInt(50), + status: FileVersionStatus.EXISTS, + createdAt: oldVersionDate, + updatedAt: oldVersionDate, + }), + ]; + + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue({ + enabled: true, + maxFileSize: 1000000, + retentionDays, + maxVersions: 10, + }); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue(existingVersions); + jest + .spyOn(fileVersionRepository, 'updateStatusBatch') + .mockResolvedValue(); + jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + + await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); + + const oldestVersionId = existingVersions[0].id; + + expect(fileVersionRepository.updateStatusBatch).toHaveBeenCalledWith( + [oldestVersionId], + FileVersionStatus.DELETED, + ); + + expect(fileVersionRepository.create).toHaveBeenCalledWith({ + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: mockFile.fileId, + size: mockFile.size, + status: FileVersionStatus.EXISTS, + }); + + expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + expect.objectContaining({ + fileId: 'new-file-id', + size: BigInt(200), + updatedAt: expect.any(Date), + }), + ); + }); + }); + + describe('When versions exist within retention period and under limit', () => { + it('then should not delete any versions', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + bucket: 'test-bucket', + type: 'pdf', + size: BigInt(100), + }, + }); + + const now = dayjs().toDate(); + const existingVersions = [ + FileVersion.build({ + id: 'version-1', + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: 'network-1', + size: BigInt(50), + status: FileVersionStatus.EXISTS, + createdAt: dayjs().subtract(5, 'day').toDate(), + updatedAt: now, + }), + FileVersion.build({ + id: 'version-2', + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: 'network-2', + size: BigInt(50), + status: FileVersionStatus.EXISTS, + createdAt: dayjs().subtract(10, 'day').toDate(), + updatedAt: now, + }), + ]; + + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue({ + enabled: true, + maxFileSize: 1000000, + retentionDays: 15, + maxVersions: 10, + }); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue(existingVersions); + const updateStatusBatchSpy = jest + .spyOn(fileVersionRepository, 'updateStatusBatch') + .mockResolvedValue(); + jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + + await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); + + expect(updateStatusBatchSpy).not.toHaveBeenCalled(); + + expect(fileVersionRepository.create).toHaveBeenCalledWith({ + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: mockFile.fileId, + size: mockFile.size, + status: FileVersionStatus.EXISTS, + }); + + expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + expect.objectContaining({ + fileId: 'new-file-id', + size: BigInt(200), + updatedAt: expect.any(Date), + }), + ); + }); + }); + + describe('When max versions limit is reached', () => { + it('then should delete oldest versions exceeding the limit', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + bucket: 'test-bucket', + type: 'pdf', + size: BigInt(100), + }, + }); + + const maxVersions = 10; + const existingVersions = Array.from( + { length: maxVersions + 2 }, + (_, i) => { + const date = dayjs().subtract(i, 'hour').toDate(); + return FileVersion.build({ + id: `version-${i}`, + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: `network-${i}`, + size: BigInt(50), + status: FileVersionStatus.EXISTS, + createdAt: date, + updatedAt: date, + }); + }, + ); + + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue({ + enabled: true, + maxFileSize: 1000000, + retentionDays: 15, + maxVersions, + }); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue(existingVersions); + jest + .spyOn(fileVersionRepository, 'updateStatusBatch') + .mockResolvedValue(); + jest.spyOn(fileVersionRepository, 'create').mockResolvedValue({} as any); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + + await action.execute(userMocked, mockFile, 'new-file-id', BigInt(200)); + + expect(fileVersionRepository.updateStatusBatch).toHaveBeenCalledWith( + expect.arrayContaining(['version-9', 'version-10', 'version-11']), + FileVersionStatus.DELETED, + ); + + expect(fileVersionRepository.create).toHaveBeenCalledWith({ + fileId: mockFile.uuid, + userId: userMocked.uuid, + networkFileId: mockFile.fileId, + size: mockFile.size, + status: FileVersionStatus.EXISTS, + }); + + expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + expect.objectContaining({ + fileId: 'new-file-id', + size: BigInt(200), + updatedAt: expect.any(Date), + }), + ); + }); + }); +}); diff --git a/src/modules/file/actions/create-file-version.action.ts b/src/modules/file/actions/create-file-version.action.ts new file mode 100644 index 000000000..ae5efaa6b --- /dev/null +++ b/src/modules/file/actions/create-file-version.action.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; +import { User } from '../../user/user.domain'; +import { File } from '../file.domain'; +import { SequelizeFileRepository } from '../file.repository'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { FileVersionStatus } from '../file-version.domain'; +import { FeatureLimitService } from '../../feature-limit/feature-limit.service'; +import { Time } from '../../../lib/time'; + +@Injectable() +export class CreateFileVersionAction { + constructor( + private readonly fileRepository: SequelizeFileRepository, + private readonly fileVersionRepository: SequelizeFileVersionRepository, + private readonly featureLimitService: FeatureLimitService, + ) {} + + async execute( + user: User, + file: File, + newFileId: string | null, + newSize: bigint, + modificationTime?: Date, + ): Promise { + await this.applyRetentionPolicy(file.uuid, user.uuid); + + await Promise.all([ + this.fileVersionRepository.create({ + fileId: file.uuid, + userId: user.uuid, + networkFileId: file.fileId, + size: file.size, + status: FileVersionStatus.EXISTS, + }), + this.fileRepository.updateByUuidAndUserId(file.uuid, user.id, { + fileId: newFileId, + size: newSize, + updatedAt: new Date(), + ...(modificationTime ? { modificationTime } : null), + }), + ]); + } + + private async applyRetentionPolicy( + fileUuid: string, + userUuid: string, + ): Promise { + const limits = + await this.featureLimitService.getFileVersioningLimits(userUuid); + + const { retentionDays, maxVersions } = limits; + + const cutoffDate = Time.daysAgo(retentionDays); + + const versions = await this.fileVersionRepository.findAllByFileId(fileUuid); + + const versionsToDeleteByAge = versions.filter( + (version) => version.createdAt < cutoffDate, + ); + + const remainingVersions = versions + .filter((version) => version.createdAt >= cutoffDate) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + const versionsToKeep = remainingVersions.slice(0, maxVersions); + const versionsToDeleteByCount = remainingVersions.slice(maxVersions); + + const versionsToDelete = [ + ...versionsToDeleteByAge, + ...versionsToDeleteByCount, + ]; + + if (versionsToKeep.length === maxVersions) { + const oldestVersion = versionsToKeep.at(-1); + versionsToDelete.push(oldestVersion); + } + + if (versionsToDelete.length > 0) { + const idsToDelete = versionsToDelete.map((v) => v.id); + await this.fileVersionRepository.updateStatusBatch( + idsToDelete, + FileVersionStatus.DELETED, + ); + } + } +} diff --git a/src/modules/file/actions/delete-file-version.action.spec.ts b/src/modules/file/actions/delete-file-version.action.spec.ts new file mode 100644 index 000000000..4abc0add4 --- /dev/null +++ b/src/modules/file/actions/delete-file-version.action.spec.ts @@ -0,0 +1,144 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { DeleteFileVersionAction } from './delete-file-version.action'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { SequelizeFileRepository } from '../file.repository'; +import { FileVersion, FileVersionStatus } from '../file-version.domain'; +import { + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { v4 } from 'uuid'; +import { newFile, newUser } from '../../../../test/fixtures'; + +describe('DeleteFileVersionAction', () => { + let action: DeleteFileVersionAction; + let fileRepository: SequelizeFileRepository; + let fileVersionRepository: SequelizeFileVersionRepository; + + const userMocked = newUser(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DeleteFileVersionAction, + { + provide: SequelizeFileRepository, + useValue: createMock(), + }, + { + provide: SequelizeFileVersionRepository, + useValue: createMock(), + }, + ], + }).compile(); + + action = module.get(DeleteFileVersionAction); + fileRepository = module.get( + SequelizeFileRepository, + ); + fileVersionRepository = module.get( + SequelizeFileVersionRepository, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(action).toBeDefined(); + }); + + describe('execute', () => { + it('When file and version exist, then should delete version', async () => { + const mockFile = newFile({ attributes: { userId: userMocked.id } }); + const versionId = v4(); + const mockVersion = FileVersion.build({ + id: versionId, + fileId: mockFile.uuid, + userId: v4(), + networkFileId: 'network-id', + size: BigInt(100), + status: FileVersionStatus.EXISTS, + createdAt: new Date(), + updatedAt: new Date(), + }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(fileVersionRepository, 'findById') + .mockResolvedValue(mockVersion); + jest + .spyOn(fileVersionRepository, 'updateStatus') + .mockResolvedValue(undefined); + + await action.execute(userMocked, mockFile.uuid, versionId); + + expect(fileRepository.findByUuid).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + {}, + ); + expect(fileVersionRepository.findById).toHaveBeenCalledWith(versionId); + expect(fileVersionRepository.updateStatus).toHaveBeenCalledWith( + versionId, + FileVersionStatus.DELETED, + ); + }); + + it('When file does not exist, then should fail', async () => { + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(null); + + await expect( + action.execute(userMocked, 'non-existent-uuid', v4()), + ).rejects.toThrow(NotFoundException); + }); + + it('When user does not own file, then should fail', async () => { + const mockFile = newFile({ attributes: { userId: 999 } }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + + await expect( + action.execute(userMocked, mockFile.uuid, v4()), + ).rejects.toThrow(ForbiddenException); + }); + + it('When version does not exist, then should fail', async () => { + const mockFile = newFile({ attributes: { userId: userMocked.id } }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest.spyOn(fileVersionRepository, 'findById').mockResolvedValue(null); + + await expect( + action.execute(userMocked, mockFile.uuid, v4()), + ).rejects.toThrow(NotFoundException); + }); + + it('When version does not belong to file, then should fail', async () => { + const mockFile = newFile({ attributes: { userId: userMocked.id } }); + const versionId = v4(); + const mockVersion = FileVersion.build({ + id: versionId, + fileId: 'different-file-uuid', + userId: v4(), + networkFileId: 'network-id', + size: BigInt(100), + status: FileVersionStatus.EXISTS, + createdAt: new Date(), + updatedAt: new Date(), + }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(fileVersionRepository, 'findById') + .mockResolvedValue(mockVersion); + + await expect( + action.execute(userMocked, mockFile.uuid, versionId), + ).rejects.toThrow(ConflictException); + }); + }); +}); diff --git a/src/modules/file/actions/delete-file-version.action.ts b/src/modules/file/actions/delete-file-version.action.ts new file mode 100644 index 000000000..4e089a7dd --- /dev/null +++ b/src/modules/file/actions/delete-file-version.action.ts @@ -0,0 +1,49 @@ +import { + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { SequelizeFileRepository } from '../file.repository'; +import { User } from '../../user/user.domain'; +import { FileVersionStatus } from '../file-version.domain'; + +@Injectable() +export class DeleteFileVersionAction { + constructor( + private readonly fileRepository: SequelizeFileRepository, + private readonly fileVersionRepository: SequelizeFileVersionRepository, + ) {} + + async execute( + user: User, + fileUuid: string, + versionId: string, + ): Promise { + const file = await this.fileRepository.findByUuid(fileUuid, user.id, {}); + + if (!file) { + throw new NotFoundException('File not found'); + } + + if (!file.isOwnedBy(user)) { + throw new ForbiddenException('You do not own this file'); + } + + const version = await this.fileVersionRepository.findById(versionId); + + if (!version) { + throw new NotFoundException('Version not found'); + } + + if (version.fileId !== fileUuid) { + throw new ConflictException('Version does not belong to this file'); + } + + await this.fileVersionRepository.updateStatus( + versionId, + FileVersionStatus.DELETED, + ); + } +} diff --git a/src/modules/file/actions/get-file-versions.action.spec.ts b/src/modules/file/actions/get-file-versions.action.spec.ts new file mode 100644 index 000000000..39a4d50cf --- /dev/null +++ b/src/modules/file/actions/get-file-versions.action.spec.ts @@ -0,0 +1,145 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { GetFileVersionsAction } from './get-file-versions.action'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { SequelizeFileRepository } from '../file.repository'; +import { FeatureLimitService } from '../../feature-limit/feature-limit.service'; +import { FileVersion, FileVersionStatus } from '../file-version.domain'; +import { NotFoundException } from '@nestjs/common'; +import { v4 } from 'uuid'; +import { + newFile, + newUser, + newVersioningLimits, +} from '../../../../test/fixtures'; + +describe('GetFileVersionsAction', () => { + let action: GetFileVersionsAction; + let fileRepository: SequelizeFileRepository; + let fileVersionRepository: SequelizeFileVersionRepository; + let featureLimitService: FeatureLimitService; + + const userMocked = newUser(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetFileVersionsAction, + { + provide: SequelizeFileRepository, + useValue: createMock(), + }, + { + provide: SequelizeFileVersionRepository, + useValue: createMock(), + }, + { + provide: FeatureLimitService, + useValue: createMock(), + }, + ], + }).compile(); + + action = module.get(GetFileVersionsAction); + fileRepository = module.get( + SequelizeFileRepository, + ); + fileVersionRepository = module.get( + SequelizeFileVersionRepository, + ); + featureLimitService = module.get(FeatureLimitService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(action).toBeDefined(); + }); + + describe('execute', () => { + const mockLimits = newVersioningLimits({ retentionDays: 30 }); + + it('When file exists, then it should return file versions', async () => { + const mockFile = newFile(); + const createdAt = new Date('2025-01-01'); + const mockVersions = [ + FileVersion.build({ + id: v4(), + fileId: mockFile.uuid, + userId: v4(), + networkFileId: 'network-1', + size: BigInt(100), + status: FileVersionStatus.EXISTS, + createdAt, + updatedAt: new Date(), + }), + ]; + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue(mockVersions); + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue(mockLimits); + + const result = await action.execute(userMocked, mockFile.uuid); + + const expectedExpiresAt = new Date(createdAt); + expectedExpiresAt.setDate( + expectedExpiresAt.getDate() + mockLimits.retentionDays, + ); + + expect(result[0].expiresAt).toEqual(expectedExpiresAt); + expect(result[0].id).toEqual(mockVersions[0].id); + expect(fileRepository.findByUuid).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + {}, + ); + expect(fileVersionRepository.findAllByFileId).toHaveBeenCalledWith( + mockFile.uuid, + ); + }); + + it('When file exists but has no versions, then it should return empty array', async () => { + const mockFile = newFile(); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(fileVersionRepository, 'findAllByFileId') + .mockResolvedValue([]); + jest + .spyOn(featureLimitService, 'getFileVersioningLimits') + .mockResolvedValue(mockLimits); + + const result = await action.execute(userMocked, mockFile.uuid); + + expect(result).toEqual([]); + expect(fileRepository.findByUuid).toHaveBeenCalledWith( + mockFile.uuid, + userMocked.id, + {}, + ); + expect(fileVersionRepository.findAllByFileId).toHaveBeenCalledWith( + mockFile.uuid, + ); + }); + + it('When file does not exist, then should fail', async () => { + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(null); + + await expect( + action.execute(userMocked, 'non-existent-uuid'), + ).rejects.toThrow(NotFoundException); + + expect(fileRepository.findByUuid).toHaveBeenCalledWith( + 'non-existent-uuid', + userMocked.id, + {}, + ); + }); + }); +}); diff --git a/src/modules/file/actions/get-file-versions.action.ts b/src/modules/file/actions/get-file-versions.action.ts new file mode 100644 index 000000000..ffe7ec7a5 --- /dev/null +++ b/src/modules/file/actions/get-file-versions.action.ts @@ -0,0 +1,40 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { SequelizeFileVersionRepository } from '../file-version.repository'; +import { SequelizeFileRepository } from '../file.repository'; +import { FeatureLimitService } from '../../feature-limit/feature-limit.service'; +import { User } from '../../user/user.domain'; +import { FileVersionDto } from '../dto/responses/file-version.dto'; + +@Injectable() +export class GetFileVersionsAction { + constructor( + private readonly fileRepository: SequelizeFileRepository, + private readonly fileVersionRepository: SequelizeFileVersionRepository, + private readonly featureLimitService: FeatureLimitService, + ) {} + + async execute(user: User, fileUuid: string): Promise { + const file = await this.fileRepository.findByUuid(fileUuid, user.id, {}); + + if (!file) { + throw new NotFoundException('File not found'); + } + + const [versions, limits] = await Promise.all([ + this.fileVersionRepository.findAllByFileId(fileUuid), + this.featureLimitService.getFileVersioningLimits(user.uuid), + ]); + + const { retentionDays } = limits; + + return versions.map((version) => { + const expiresAt = new Date(version.createdAt); + expiresAt.setDate(expiresAt.getDate() + retentionDays); + + return { + ...version.toJSON(), + expiresAt, + }; + }); + } +} diff --git a/src/modules/file/actions/index.ts b/src/modules/file/actions/index.ts new file mode 100644 index 000000000..a78ed9562 --- /dev/null +++ b/src/modules/file/actions/index.ts @@ -0,0 +1,3 @@ +export * from './get-file-versions.action'; +export * from './delete-file-version.action'; +export * from './create-file-version.action'; diff --git a/src/modules/file/file-version.domain.spec.ts b/src/modules/file/file-version.domain.spec.ts index fe4bee07a..423e2dc02 100644 --- a/src/modules/file/file-version.domain.spec.ts +++ b/src/modules/file/file-version.domain.spec.ts @@ -8,6 +8,7 @@ describe('FileVersion Domain', () => { const mockAttributes: FileVersionAttributes = { id: 'version-id-123', fileId: 'file-id-456', + userId: 'user-uuid-789', networkFileId: 'network-file-id-789', size: BigInt(1024), status: FileVersionStatus.EXISTS, @@ -63,6 +64,7 @@ describe('FileVersion Domain', () => { expect(json).toEqual({ id: mockAttributes.id, fileId: mockAttributes.fileId, + userId: mockAttributes.userId, networkFileId: mockAttributes.networkFileId, size: mockAttributes.size, status: mockAttributes.status, diff --git a/src/modules/file/file-version.domain.ts b/src/modules/file/file-version.domain.ts index 05b36bde2..e396d6539 100644 --- a/src/modules/file/file-version.domain.ts +++ b/src/modules/file/file-version.domain.ts @@ -6,6 +6,7 @@ export enum FileVersionStatus { export interface FileVersionAttributes { id: string; fileId: string; + userId: string; networkFileId: string; size: bigint; status: FileVersionStatus; @@ -16,6 +17,7 @@ export interface FileVersionAttributes { export class FileVersion implements FileVersionAttributes { id: string; fileId: string; + userId: string; networkFileId: string; size: bigint; status: FileVersionStatus; @@ -25,6 +27,7 @@ export class FileVersion implements FileVersionAttributes { private constructor(attributes: FileVersionAttributes) { this.id = attributes.id; this.fileId = attributes.fileId; + this.userId = attributes.userId; this.networkFileId = attributes.networkFileId; this.size = attributes.size; this.status = attributes.status; @@ -48,6 +51,7 @@ export class FileVersion implements FileVersionAttributes { return { id: this.id, fileId: this.fileId, + userId: this.userId, networkFileId: this.networkFileId, size: this.size, status: this.status, diff --git a/src/modules/file/file-version.model.ts b/src/modules/file/file-version.model.ts index 9aa400481..691b66c3f 100644 --- a/src/modules/file/file-version.model.ts +++ b/src/modules/file/file-version.model.ts @@ -9,6 +9,7 @@ import { Table, } from 'sequelize-typescript'; import { FileModel } from './file.model'; +import { UserModel } from '../user/user.model'; import { FileVersionAttributes, FileVersionStatus, @@ -33,6 +34,13 @@ export class FileVersionModel extends Model implements FileVersionAttributes { @BelongsTo(() => FileModel, 'fileId') file: FileModel; + @ForeignKey(() => UserModel) + @Column(DataType.STRING(36)) + userId: string; + + @BelongsTo(() => UserModel, 'userId') + user: UserModel; + @Column(DataType.STRING) networkFileId: string; diff --git a/src/modules/file/file-version.repository.spec.ts b/src/modules/file/file-version.repository.spec.ts index 6bbee7f4a..5566d1179 100644 --- a/src/modules/file/file-version.repository.spec.ts +++ b/src/modules/file/file-version.repository.spec.ts @@ -29,6 +29,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -37,6 +38,7 @@ describe('SequelizeFileVersionRepository', () => { expect(result).toBeInstanceOf(FileVersion); expect(fileVersionModel.create).toHaveBeenCalledWith({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -53,6 +55,7 @@ describe('SequelizeFileVersionRepository', () => { await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, } as any); @@ -76,6 +79,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -95,6 +99,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -208,6 +213,7 @@ describe('SequelizeFileVersionRepository', () => { const result = await repository.upsert({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -217,6 +223,7 @@ describe('SequelizeFileVersionRepository', () => { expect(fileVersionModel.upsert).toHaveBeenCalledWith( expect.objectContaining({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status, @@ -403,4 +410,67 @@ describe('SequelizeFileVersionRepository', () => { ); }); }); + + describe('sumExistingSizesByUser', () => { + it('When user has versions, then it returns the sum', async () => { + const userId = 'user-uuid-123'; + const totalSize = 5000; + + jest + .spyOn(fileVersionModel, 'findAll') + .mockResolvedValue([{ total: totalSize }] as any); + + const result = await repository.sumExistingSizesByUser(userId); + + expect(result).toBe(totalSize); + expect(fileVersionModel.findAll).toHaveBeenCalledWith({ + attributes: expect.any(Array), + where: { + userId, + status: FileVersionStatus.EXISTS, + }, + raw: true, + }); + }); + + it('When user has no versions, then it returns 0', async () => { + const userId = 'user-uuid-456'; + + jest + .spyOn(fileVersionModel, 'findAll') + .mockResolvedValue([{ total: null }] as any); + + const result = await repository.sumExistingSizesByUser(userId); + + expect(result).toBe(0); + }); + + it('When query returns empty array, then it returns 0', async () => { + const userId = 'user-uuid-789'; + + jest.spyOn(fileVersionModel, 'findAll').mockResolvedValue([] as any); + + const result = await repository.sumExistingSizesByUser(userId); + + expect(result).toBe(0); + }); + + it('When summing sizes, then it only counts EXISTS status versions', async () => { + const userId = 'user-uuid-abc'; + + jest + .spyOn(fileVersionModel, 'findAll') + .mockResolvedValue([{ total: 1000 }] as any); + + await repository.sumExistingSizesByUser(userId); + + expect(fileVersionModel.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: FileVersionStatus.EXISTS, + }), + }), + ); + }); + }); }); diff --git a/src/modules/file/file-version.repository.ts b/src/modules/file/file-version.repository.ts index 02e4a4d6e..1fa44a4ce 100644 --- a/src/modules/file/file-version.repository.ts +++ b/src/modules/file/file-version.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; +import { Sequelize } from 'sequelize'; import { FileVersionModel } from './file-version.model'; import { FileVersion, @@ -20,6 +21,7 @@ export interface FileVersionRepository { updateStatus(id: string, status: FileVersionStatus): Promise; updateStatusBatch(ids: string[], status: FileVersionStatus): Promise; deleteAllByFileId(fileId: string): Promise; + sumExistingSizesByUser(userId: string): Promise; } @Injectable() @@ -32,6 +34,7 @@ export class SequelizeFileVersionRepository implements FileVersionRepository { async create(version: CreateFileVersionData): Promise { const createdVersion = await this.model.create({ fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status || FileVersionStatus.EXISTS, @@ -44,6 +47,7 @@ export class SequelizeFileVersionRepository implements FileVersionRepository { const [instance] = await this.model.upsert( { fileId: version.fileId, + userId: version.userId, networkFileId: version.networkFileId, size: version.size, status: version.status || FileVersionStatus.EXISTS, @@ -108,4 +112,17 @@ export class SequelizeFileVersionRepository implements FileVersionRepository { }, ); } + + async sumExistingSizesByUser(userId: string): Promise { + const result = await this.model.findAll({ + attributes: [[Sequelize.fn('SUM', Sequelize.col('size')), 'total']], + where: { + userId, + status: FileVersionStatus.EXISTS, + }, + raw: true, + }); + + return Number(result[0]?.['total']) || 0; + } } diff --git a/src/modules/file/file.controller.spec.ts b/src/modules/file/file.controller.spec.ts index 5bd3cc3b7..f5e1e2de8 100644 --- a/src/modules/file/file.controller.spec.ts +++ b/src/modules/file/file.controller.spec.ts @@ -625,6 +625,7 @@ describe('FileController', () => { userMocked, validUuid, replaceFileDto, + null, ); expect(storageNotificationService.fileUpdated).toHaveBeenCalledWith({ payload: replacedFile, diff --git a/src/modules/file/file.controller.ts b/src/modules/file/file.controller.ts index 39e96970c..889cd36e8 100644 --- a/src/modules/file/file.controller.ts +++ b/src/modules/file/file.controller.ts @@ -56,6 +56,10 @@ import { CreateThumbnailDto } from '../thumbnail/dto/create-thumbnail.dto'; import { ThumbnailUseCases } from '../thumbnail/thumbnail.usecase'; import { RequestLoggerInterceptor } from '../../middlewares/requests-logger.interceptor'; import { Version } from '../../common/decorators/version.decorator'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CustomThrottle } from '../../guards/custom-endpoint-throttle.decorator'; +import { Workspace as WorkspaceDecorator } from '../auth/decorators/workspace.decorator'; +import { Workspace } from '../workspaces/domains/workspaces.domain'; @ApiTags('File') @Controller('files') @@ -261,12 +265,19 @@ export class FileController { @Body() fileData: ReplaceFileDto, @Client() clientId: string, @Requester() requester: User, + @WorkspaceDecorator() workspace?: Workspace, ): Promise { try { const file = await this.fileUseCases.replaceFile( user, fileUuid, fileData, + workspace + ? { + workspace, + memberId: requester.uuid, + } + : null, ); this.storageNotificationService.fileUpdated({ @@ -345,6 +356,10 @@ export class FileController { return result; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 100 }, + }) @Get('/') @ApiOkResponse({ isArray: true, type: FileDto }) async getFiles( @@ -449,6 +464,10 @@ export class FileController { return files; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 100 }, + }) @Get('/meta') @ApiOkResponse({ type: FileDto }) async getFileMetaByPath( diff --git a/src/modules/file/file.module.ts b/src/modules/file/file.module.ts index 22e02c93b..b419202fb 100644 --- a/src/modules/file/file.module.ts +++ b/src/modules/file/file.module.ts @@ -21,6 +21,12 @@ import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; import { RedisService } from '../../externals/redis/redis.service'; import { TrashModule } from '../trash/trash.module'; import { CacheManagerModule } from '../cache-manager/cache-manager.module'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { + DeleteFileVersionAction, + GetFileVersionsAction, + CreateFileVersionAction, +} from './actions'; @Module({ imports: [ @@ -45,6 +51,10 @@ import { CacheManagerModule } from '../cache-manager/cache-manager.module'; FileUseCases, MailerService, RedisService, + CustomEndpointThrottleGuard, + GetFileVersionsAction, + DeleteFileVersionAction, + CreateFileVersionAction, ], exports: [ FileUseCases, diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts index 3c36ee490..1961dc71b 100644 --- a/src/modules/file/file.repository.ts +++ b/src/modules/file/file.repository.ts @@ -104,6 +104,10 @@ export interface FileRepository { getFilesWhoseFolderIdDoesNotExist(userId: File['userId']): Promise; getFilesCountWhere(where: Partial): Promise; getZeroSizeFilesCountByUser(userId: User['id']): Promise; + getZeroSizeFilesCountInWorkspaceByMember( + createdBy: WorkspaceItemUserAttributes['createdBy'], + workspaceId: WorkspaceAttributes['id'], + ): Promise; updateFilesStatusToTrashed( user: User, fileIds: File['fileId'][], @@ -469,6 +473,32 @@ export class SequelizeFileRepository implements FileRepository { return files.map(this.toDomain.bind(this)); } + async getZeroSizeFilesCountInWorkspaceByMember( + createdBy: WorkspaceItemUserAttributes['createdBy'], + workspaceId: WorkspaceAttributes['id'], + ): Promise { + const { count } = await this.fileModel.findAndCountAll({ + where: { + size: 0, + status: { + [Op.not]: FileStatus.DELETED, + }, + }, + include: [ + { + model: WorkspaceItemUserModel, + where: { + createdBy, + workspaceId, + itemType: WorkspaceItemType.File, + }, + }, + ], + }); + + return count; + } + async getSumSizeOfFilesInWorkspaceByStatuses( createdBy: WorkspaceItemUserAttributes['createdBy'], workspaceId: WorkspaceAttributes['id'], diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index b05f76c93..9f2471823 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -48,6 +48,11 @@ import { UserUseCases } from '../user/user.usecase'; import { TrashUseCases } from '../trash/trash.usecase'; import { TrashItemType } from '../trash/trash.attributes'; import { CacheManagerService } from '../cache-manager/cache-manager.service'; +import { + DeleteFileVersionAction, + GetFileVersionsAction, + CreateFileVersionAction, +} from './actions'; const fileId = '6295c99a241bb000083f1c6a'; const userId = 1; @@ -68,6 +73,10 @@ describe('FileUseCases', () => { let redisService: RedisService; let trashUsecases: TrashUseCases; let cacheManagerService: CacheManagerService; + let getFileVersionsAction: GetFileVersionsAction; + let deleteFileVersionAction: DeleteFileVersionAction; + let createFileVersionAction: CreateFileVersionAction; + let userUsecases: UserUseCases; const userMocked = newUser({ attributes: { @@ -99,6 +108,16 @@ describe('FileUseCases', () => { redisService = module.get(RedisService); trashUsecases = module.get(TrashUseCases); cacheManagerService = module.get(CacheManagerService); + getFileVersionsAction = module.get( + GetFileVersionsAction, + ); + deleteFileVersionAction = module.get( + DeleteFileVersionAction, + ); + createFileVersionAction = module.get( + CreateFileVersionAction, + ); + userUsecases = module.get(UserUseCases); }); afterEach(() => { @@ -371,6 +390,7 @@ describe('FileUseCases', () => { ...fileAttributes, name: encryptedName, folderId, + plainName: null, }; const decryptedName = 'decryptedName'; @@ -393,6 +413,22 @@ describe('FileUseCases', () => { ); }); + it('When the file has a plain name, then the plain name is returned', () => { + const file = File.build({ + ...fileAttributes, + plainName: 'plain name', + }); + + const result = service.decrypFileName(file); + expect(result).toEqual( + File.build({ + ...file, + name: 'plain name', + plainName: 'plain name', + }), + ); + }); + it('fails when name is not encrypted', () => { const decyptedName = 'not encrypted name'; @@ -1008,6 +1044,224 @@ describe('FileUseCases', () => { }); }); + describe('Empty files creation in workspace', () => { + it('When creating empty file with workspace options, then it should check workspace limit', async () => { + const folder = newFolder({ attributes: { userId: userMocked.id } }); + const workspaceOwner = newUser(); + const workspace = newWorkspace({ + attributes: { ownerId: workspaceOwner.uuid }, + }); + const emptyFileDto: CreateFileDto = { + ...newFileDto, + size: BigInt(0), + }; + const workspaceOptions = { + workspace, + memberId: userMocked.uuid, + }; + + const mockLimit = newFeatureLimit({ + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + value: '1000', + }); + + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValueOnce(folder); + jest + .spyOn(fileRepository, 'findByPlainNameAndFolderId') + .mockResolvedValueOnce(null); + jest + .spyOn(userUsecases, 'findByUuid') + .mockResolvedValue(workspaceOwner); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountInWorkspaceByMember') + .mockResolvedValue(5); + + const createdFile = newFile({ + attributes: { + ...emptyFileDto, + id: 1, + folderId: folder.id, + folderUuid: folder.uuid, + userId: userMocked.id, + uuid: v4(), + status: FileStatus.EXISTS, + }, + }); + + jest.spyOn(fileRepository, 'create').mockResolvedValueOnce(createdFile); + + const result = await service.createFile( + userMocked, + emptyFileDto, + undefined, + workspaceOptions, + ); + + expect(result).toEqual(createdFile); + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).toHaveBeenCalledWith(userMocked.uuid, workspace.id); + expect( + fileRepository.getZeroSizeFilesCountByUser, + ).not.toHaveBeenCalled(); + }); + + it('When creating empty file with workspace options and limit is reached, then it should throw', async () => { + const folder = newFolder({ attributes: { userId: userMocked.id } }); + const workspaceOwner = newUser(); + const workspace = newWorkspace({ + attributes: { ownerId: workspaceOwner.uuid }, + }); + const emptyFileDto: CreateFileDto = { + ...newFileDto, + size: BigInt(0), + }; + const workspaceOptions = { + workspace, + memberId: userMocked.uuid, + }; + + const mockLimit = newFeatureLimit({ + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + value: '1000', + }); + + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValueOnce(folder); + jest + .spyOn(fileRepository, 'findByPlainNameAndFolderId') + .mockResolvedValueOnce(null); + jest + .spyOn(userUsecases, 'findByUuid') + .mockResolvedValue(workspaceOwner); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountInWorkspaceByMember') + .mockResolvedValue(1000); + + await expect( + service.createFile( + userMocked, + emptyFileDto, + undefined, + workspaceOptions, + ), + ).rejects.toThrow(BadRequestException); + + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).toHaveBeenCalledWith(userMocked.uuid, workspace.id); + expect( + fileRepository.getZeroSizeFilesCountByUser, + ).not.toHaveBeenCalled(); + }); + + it('When creating empty file without workspace options, then it should check individual limit', async () => { + const folder = newFolder({ attributes: { userId: userMocked.id } }); + const emptyFileDto: CreateFileDto = { + ...newFileDto, + size: BigInt(0), + }; + + const mockLimit = newFeatureLimit({ + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + value: '1000', + }); + + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValueOnce(folder); + jest + .spyOn(fileRepository, 'findByPlainNameAndFolderId') + .mockResolvedValueOnce(null); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountByUser') + .mockResolvedValue(5); + + const createdFile = newFile({ + attributes: { + ...emptyFileDto, + id: 1, + folderId: folder.id, + folderUuid: folder.uuid, + userId: userMocked.id, + uuid: v4(), + status: FileStatus.EXISTS, + }, + }); + + jest.spyOn(fileRepository, 'create').mockResolvedValueOnce(createdFile); + + const result = await service.createFile(userMocked, emptyFileDto); + + expect(result).toEqual(createdFile); + expect(featureLimitService.getUserLimitByLabel).toHaveBeenCalledWith( + LimitLabels.MaxZeroSizeFiles, + userMocked, + ); + expect(fileRepository.getZeroSizeFilesCountByUser).toHaveBeenCalledWith( + userMocked.id, + ); + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).not.toHaveBeenCalled(); + }); + + it('When creating non-empty file with workspace options, then it should not check limits', async () => { + const folder = newFolder({ attributes: { userId: userMocked.id } }); + const workspace = newWorkspace(); + const fileDto: CreateFileDto = { + ...newFileDto, + size: BigInt(1024), + }; + const workspaceOptions = { + workspace, + memberId: userMocked.uuid, + }; + + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValueOnce(folder); + jest + .spyOn(fileRepository, 'findByPlainNameAndFolderId') + .mockResolvedValueOnce(null); + + const createdFile = newFile({ + attributes: { + ...fileDto, + id: 1, + folderId: folder.id, + folderUuid: folder.uuid, + userId: userMocked.id, + uuid: v4(), + status: FileStatus.EXISTS, + }, + }); + + jest.spyOn(fileRepository, 'create').mockResolvedValueOnce(createdFile); + + await service.createFile( + userMocked, + fileDto, + undefined, + workspaceOptions, + ); + + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).not.toHaveBeenCalled(); + expect( + fileRepository.getZeroSizeFilesCountByUser, + ).not.toHaveBeenCalled(); + }); + }); + describe('first upload email functionality', () => { it('When user has no previous files, then should send first upload email', async () => { const folder = newFolder({ attributes: { userId: userMocked.id } }); @@ -1216,6 +1470,67 @@ describe('FileUseCases', () => { }); }); + describe('checkWorkspaceEmptyFilesLimit', () => { + it('When workspace owner limit is enforced, then it should throw', async () => { + const workspaceOwner = newUser(); + const member = newUser(); + const workspace = newWorkspace({ + attributes: { ownerId: workspaceOwner.uuid }, + }); + const mockLimit = newFeatureLimit({ + value: '10', + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + }); + + jest.spyOn(userUsecases, 'findByUuid').mockResolvedValue(workspaceOwner); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountInWorkspaceByMember') + .mockResolvedValue(10); + + await expect( + service.checkWorkspaceEmptyFilesLimit(member.uuid, workspace), + ).rejects.toThrow(BadRequestException); + }); + + it('When workspace owner limit is NOT enforced, then it should not throw', async () => { + const workspaceOwner = newUser(); + const member = newUser(); + const workspace = newWorkspace({ + attributes: { ownerId: workspaceOwner.uuid }, + }); + const mockLimit = newFeatureLimit({ + value: '1000', + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + }); + + jest.spyOn(userUsecases, 'findByUuid').mockResolvedValue(workspaceOwner); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountInWorkspaceByMember') + .mockResolvedValue(5); + + await expect( + service.checkWorkspaceEmptyFilesLimit(member.uuid, workspace), + ).resolves.not.toThrow(); + + expect(userUsecases.findByUuid).toHaveBeenCalledWith(workspace.ownerId); + expect(featureLimitService.getUserLimitByLabel).toHaveBeenCalledWith( + LimitLabels.MaxZeroSizeFiles, + workspaceOwner, + ); + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).toHaveBeenCalledWith(member.uuid, workspace.id); + }); + }); + describe('updateFileMetaData', () => { it('When a file with the same name already exists in the folder, then it should fail', async () => { const newFileMeta: UpdateFileMetaDto = { plainName: 'new-name' }; @@ -1620,31 +1935,101 @@ describe('FileUseCases', () => { }); describe('getUserUsedStorage', () => { - it('When called, it should return the user total used space', async () => { - const totalUsage = 1000; + it('When called, it should return the sum of files and versions usage', async () => { + const filesUsage = 1000; + const versionsUsage = 500; + const expectedTotal = filesUsage + versionsUsage; + + jest + .spyOn(service, 'getUserUsedStorageIncrementally') + .mockResolvedValueOnce(filesUsage); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); + + const result = await service.getUserUsedStorage(userMocked); + + expect(result).toEqual(expectedTotal); + expect(service.getUserUsedStorageIncrementally).toHaveBeenCalledWith( + userMocked, + ); + expect(fileVersionRepository.sumExistingSizesByUser).toHaveBeenCalledWith( + userMocked.uuid, + ); + }); + + it('When user has only files usage, then it returns files usage', async () => { + const filesUsage = 1000; + const versionsUsage = 0; + + jest + .spyOn(service, 'getUserUsedStorageIncrementally') + .mockResolvedValueOnce(filesUsage); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); + + const result = await service.getUserUsedStorage(userMocked); + + expect(result).toEqual(filesUsage); + }); + + it('When user has only versions usage, then it returns versions usage', async () => { + const filesUsage = 0; + const versionsUsage = 500; + jest .spyOn(service, 'getUserUsedStorageIncrementally') - .mockResolvedValueOnce(totalUsage); + .mockResolvedValueOnce(filesUsage); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); const result = await service.getUserUsedStorage(userMocked); - expect(result).toEqual(totalUsage); + + expect(result).toEqual(versionsUsage); }); - it('When getUserUsedStorageIncrementally returns null, it should return 0', async () => { + it('When getUserUsedStorageIncrementally returns null, it should treat as 0', async () => { + const versionsUsage = 500; + jest .spyOn(service, 'getUserUsedStorageIncrementally') .mockResolvedValueOnce(null); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); const result = await service.getUserUsedStorage(userMocked); - expect(result).toEqual(0); + + expect(result).toEqual(versionsUsage); }); - it('When getUserUsedStorageIncrementally returns undefined, it should return 0', async () => { + it('When getUserUsedStorageIncrementally returns undefined, it should treat as 0', async () => { + const versionsUsage = 500; + jest .spyOn(service, 'getUserUsedStorageIncrementally') .mockResolvedValueOnce(undefined); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(versionsUsage); + + const result = await service.getUserUsedStorage(userMocked); + + expect(result).toEqual(versionsUsage); + }); + + it('When both return null/undefined, it should return 0', async () => { + jest + .spyOn(service, 'getUserUsedStorageIncrementally') + .mockResolvedValueOnce(null); + jest + .spyOn(fileVersionRepository, 'sumExistingSizesByUser') + .mockResolvedValueOnce(0); const result = await service.getUserUsedStorage(userMocked); + expect(result).toEqual(0); }); }); @@ -1826,7 +2211,12 @@ describe('FileUseCases', () => { const result = await service.getFileMetadata(userMocked, mockFile.uuid); - expect(result).toEqual(mockFile); + expect(result).toEqual( + File.build({ + ...mockFile, + name: mockFile.plainName, + }), + ); expect(fileRepository.findByUuid).toHaveBeenCalledWith( mockFile.uuid, userMocked.id, @@ -1843,52 +2233,24 @@ describe('FileUseCases', () => { }); describe('getFileVersions', () => { - const mockLimits = newVersioningLimits({ retentionDays: 30 }); - - it('When file exists, then it should return versions with expiresAt', async () => { + it('When file exists, then it should return file versions', async () => { const mockFile = newFile(); - const createdAt = new Date('2025-01-01'); - const mockVersions = [ - FileVersion.build({ - id: v4(), - fileId: mockFile.uuid, - networkFileId: 'network-1', - size: BigInt(100), - status: FileVersionStatus.EXISTS, - createdAt, - updatedAt: new Date(), - }), - ]; - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); - jest - .spyOn(fileVersionRepository, 'findAllByFileId') - .mockResolvedValue(mockVersions); - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue(mockLimits); + jest.spyOn(getFileVersionsAction, 'execute').mockResolvedValue([]); const result = await service.getFileVersions(userMocked, mockFile.uuid); - const expectedExpiresAt = new Date(createdAt); - expectedExpiresAt.setDate( - expectedExpiresAt.getDate() + mockLimits.retentionDays, - ); - - expect(result[0].expiresAt).toEqual(expectedExpiresAt); - expect(result[0].id).toEqual(mockVersions[0].id); - expect(fileRepository.findByUuid).toHaveBeenCalledWith( - mockFile.uuid, - userMocked.id, - {}, - ); - expect(fileVersionRepository.findAllByFileId).toHaveBeenCalledWith( + expect(result).toEqual([]); + expect(getFileVersionsAction.execute).toHaveBeenCalledWith( + userMocked, mockFile.uuid, ); }); - it('When file does not exist, then it should throw NotFoundException', async () => { - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(null); + it('When file does not exist, then should fail', async () => { + const error = new NotFoundException('File not found'); + + jest.spyOn(getFileVersionsAction, 'execute').mockRejectedValue(error); await expect( service.getFileVersions(userMocked, 'non-existent-uuid'), @@ -1897,98 +2259,22 @@ describe('FileUseCases', () => { }); describe('deleteFileVersion', () => { - it('When file and version exist, then it should delete the version', async () => { + it('When deletion fails, then error is propagated', async () => { const mockFile = newFile({ attributes: { userId: userMocked.id } }); const versionId = v4(); - const mockVersion = FileVersion.build({ - id: versionId, - fileId: mockFile.uuid, - networkFileId: 'network-id', - size: BigInt(100), - status: FileVersionStatus.EXISTS, - createdAt: new Date('2024-01-01'), - updatedAt: new Date(), - }); - - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); - jest - .spyOn(fileVersionRepository, 'findById') - .mockResolvedValue(mockVersion); - jest.spyOn(fileVersionRepository, 'updateStatus').mockResolvedValue(); - - await service.deleteFileVersion(userMocked, mockFile.uuid, versionId); - - expect(fileVersionRepository.updateStatus).toHaveBeenCalledWith( - versionId, - FileVersionStatus.DELETED, - ); - }); + const error = new NotFoundException('File not found'); - it('When file does not exist, then it should throw NotFoundException', async () => { - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(null); - - await expect( - service.deleteFileVersion(userMocked, 'non-existent-uuid', v4()), - ).rejects.toThrow(NotFoundException); - }); - - it('When version does not exist, then it should throw NotFoundException', async () => { - const mockFile = newFile({ attributes: { userId: userMocked.id } }); - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); - jest.spyOn(fileVersionRepository, 'findById').mockResolvedValue(null); + jest.spyOn(deleteFileVersionAction, 'execute').mockRejectedValue(error); await expect( - service.deleteFileVersion(userMocked, mockFile.uuid, v4()), + service.deleteFileVersion(userMocked, mockFile.uuid, versionId), ).rejects.toThrow(NotFoundException); - }); - it('When version does not belong to file, then it should throw BadRequestException', async () => { - const mockFile = newFile({ attributes: { userId: userMocked.id } }); - const mockVersion = FileVersion.build({ - id: v4(), - fileId: 'different-file-uuid', - networkFileId: 'network-id', - size: BigInt(100), - status: FileVersionStatus.EXISTS, - createdAt: new Date(), - updatedAt: new Date(), - }); - - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); - jest - .spyOn(fileVersionRepository, 'findById') - .mockResolvedValue(mockVersion); - - await expect( - service.deleteFileVersion(userMocked, mockFile.uuid, mockVersion.id), - ).rejects.toThrow(BadRequestException); - }); - - it('When deleting a version, then file should not be modified', async () => { - const mockFile = newFile({ attributes: { userId: userMocked.id } }); - const versionId = v4(); - const mockVersion = FileVersion.build({ - id: versionId, - fileId: mockFile.uuid, - networkFileId: 'old-network-id', - size: BigInt(100), - status: FileVersionStatus.EXISTS, - createdAt: new Date(), - updatedAt: new Date(), - }); - - jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); - jest - .spyOn(fileVersionRepository, 'findById') - .mockResolvedValue(mockVersion); - jest.spyOn(fileVersionRepository, 'updateStatus').mockResolvedValue(); - const updateFileSpy = jest - .spyOn(fileRepository, 'updateByUuidAndUserId') - .mockResolvedValue(); - - await service.deleteFileVersion(userMocked, mockFile.uuid, versionId); - - expect(updateFileSpy).not.toHaveBeenCalled(); + expect(deleteFileVersionAction.execute).toHaveBeenCalledWith( + userMocked, + mockFile.uuid, + versionId, + ); }); }); @@ -1999,6 +2285,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: versionId, fileId: mockFile.uuid, + userId: v4(), networkFileId: 'old-network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -2061,6 +2348,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: v4(), fileId: 'different-file-uuid', + userId: v4(), networkFileId: 'network-id', size: BigInt(100), status: FileVersionStatus.EXISTS, @@ -2083,6 +2371,7 @@ describe('FileUseCases', () => { const mockVersion = FileVersion.build({ id: v4(), fileId: mockFile.uuid, + userId: v4(), networkFileId: 'network-id', size: BigInt(100), status: FileVersionStatus.DELETED, @@ -2216,13 +2505,9 @@ describe('FileUseCases', () => { jest .spyOn(service, 'isFileVersionable') .mockResolvedValue({ versionable: true, limits: null }); - const applyRetentionSpy = jest - .spyOn(service as any, 'applyRetentionPolicy') + const createVersionSpy = jest + .spyOn(createFileVersionAction, 'execute') .mockResolvedValue(undefined); - const upsertSpy = jest - .spyOn(fileVersionRepository, 'upsert') - .mockResolvedValue({} as any); - jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); const deleteFileSpy = jest.spyOn(bridgeService, 'deleteFile'); const result = await service.replaceFile( @@ -2231,17 +2516,13 @@ describe('FileUseCases', () => { replaceData, ); - expect(applyRetentionSpy).toHaveBeenCalledWith( - mockFile.uuid, - userMocked.uuid, + expect(createVersionSpy).toHaveBeenCalledWith( + userMocked, + mockFile, + replaceData.fileId, + replaceData.size, + undefined, ); - expect(upsertSpy).toHaveBeenCalledWith({ - fileId: mockFile.uuid, - networkFileId: mockFile.fileId, - size: mockFile.size, - status: 'EXISTS', - }); - expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalled(); expect(deleteFileSpy).not.toHaveBeenCalled(); expect(result).toEqual({ ...mockFile.toJSON(), @@ -2267,18 +2548,13 @@ describe('FileUseCases', () => { jest .spyOn(service, 'isFileVersionable') .mockResolvedValue({ versionable: false, limits: null }); - const applyRetentionSpy = jest.spyOn( - service as any, - 'applyRetentionPolicy', - ); - const upsertSpy = jest.spyOn(fileVersionRepository, 'upsert'); + const createVersionSpy = jest.spyOn(createFileVersionAction, 'execute'); jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); jest.spyOn(bridgeService, 'deleteFile').mockResolvedValue(); await service.replaceFile(userMocked, mockFile.uuid, replaceData); - expect(applyRetentionSpy).not.toHaveBeenCalled(); - expect(upsertSpy).not.toHaveBeenCalled(); + expect(createVersionSpy).not.toHaveBeenCalled(); expect(bridgeService.deleteFile).toHaveBeenCalledWith( userMocked, mockFile.bucket, @@ -2303,18 +2579,13 @@ describe('FileUseCases', () => { jest .spyOn(service, 'isFileVersionable') .mockResolvedValue({ versionable: false, limits: null }); - const applyRetentionSpy = jest.spyOn( - service as any, - 'applyRetentionPolicy', - ); - const upsertSpy = jest.spyOn(fileVersionRepository, 'upsert'); + const createVersionSpy = jest.spyOn(createFileVersionAction, 'execute'); jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); jest.spyOn(bridgeService, 'deleteFile').mockResolvedValue(); await service.replaceFile(userMocked, mockFile.uuid, replaceData); - expect(applyRetentionSpy).not.toHaveBeenCalled(); - expect(upsertSpy).not.toHaveBeenCalled(); + expect(createVersionSpy).not.toHaveBeenCalled(); expect(bridgeService.deleteFile).toHaveBeenCalledWith( userMocked, mockFile.bucket, @@ -2458,6 +2729,163 @@ describe('FileUseCases', () => { ); }); }); + + describe('Empty file replacement in workspace', () => { + it('When replacing with empty file with workspace options, then it should check workspace limit', async () => { + const workspaceOwner = newUser(); + const member = newUser(); + const requester = newUser(); + const workspace = newWorkspace({ + attributes: { ownerId: workspaceOwner.uuid }, + }); + const mockFile = newFile({ + owner: member, + attributes: { + fileId: 'old-file-id', + size: BigInt(100), + }, + }); + const replaceData = { + size: BigInt(0), + }; + const workspaceOptions = { + workspace, + memberId: requester.uuid, + }; + const mockLimit = newFeatureLimit({ + value: '1000', + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(userUsecases, 'findByUuid') + .mockResolvedValue(workspaceOwner); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountInWorkspaceByMember') + .mockResolvedValue(0); + jest + .spyOn(service, 'isFileVersionable') + .mockResolvedValue({ versionable: false, limits: null }); + const updateSpy = jest + .spyOn(fileRepository, 'updateByUuidAndUserId') + .mockResolvedValue(); + jest.spyOn(bridgeService, 'deleteFile').mockResolvedValue(); + + const result = await service.replaceFile( + member, + mockFile.uuid, + replaceData, + workspaceOptions, + ); + + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).toHaveBeenCalledWith(requester.uuid, workspace.id); + expect(updateSpy).toHaveBeenCalledWith( + mockFile.uuid, + member.id, + expect.objectContaining({ + fileId: null, + size: BigInt(0), + }), + ); + expect(result.fileId).toBeNull(); + expect(result.size).toBe(BigInt(0)); + }); + + it('When replacing with empty file without workspace options, then it should check individual limit', async () => { + const mockFile = newFile({ + attributes: { + fileId: 'old-file-id', + size: BigInt(100), + }, + }); + const replaceData = { + size: BigInt(0), + }; + const mockLimit = newFeatureLimit({ + value: '1000', + label: LimitLabels.MaxZeroSizeFiles, + type: LimitTypes.Counter, + }); + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(featureLimitService, 'getUserLimitByLabel') + .mockResolvedValue(mockLimit); + jest + .spyOn(fileRepository, 'getZeroSizeFilesCountByUser') + .mockResolvedValue(0); + jest + .spyOn(service, 'isFileVersionable') + .mockResolvedValue({ versionable: false, limits: null }); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + jest.spyOn(bridgeService, 'deleteFile').mockResolvedValue(); + + await service.replaceFile(userMocked, mockFile.uuid, replaceData); + + expect(featureLimitService.getUserLimitByLabel).toHaveBeenCalledWith( + LimitLabels.MaxZeroSizeFiles, + userMocked, + ); + expect(fileRepository.getZeroSizeFilesCountByUser).toHaveBeenCalledWith( + userMocked.id, + ); + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).not.toHaveBeenCalled(); + }); + + it('When replacing with non-empty file with workspace options, then it should not check limits', async () => { + const workspaceOwner = newUser(); + const member = newUser(); + const requester = newUser(); + const workspace = newWorkspace({ + attributes: { ownerId: workspaceOwner.uuid }, + }); + const mockFile = newFile({ + owner: member, + attributes: { + fileId: 'old-file-id', + size: BigInt(100), + }, + }); + const replaceData = { + fileId: 'new-file-id', + size: BigInt(1024), + }; + const workspaceOptions = { + workspace, + memberId: requester.uuid, + }; + + jest.spyOn(fileRepository, 'findByUuid').mockResolvedValue(mockFile); + jest + .spyOn(service, 'isFileVersionable') + .mockResolvedValue({ versionable: false, limits: null }); + jest.spyOn(fileRepository, 'updateByUuidAndUserId').mockResolvedValue(); + jest.spyOn(bridgeService, 'deleteFile').mockResolvedValue(); + + await service.replaceFile( + member, + mockFile.uuid, + replaceData, + workspaceOptions, + ); + + expect( + fileRepository.getZeroSizeFilesCountInWorkspaceByMember, + ).not.toHaveBeenCalled(); + expect( + fileRepository.getZeroSizeFilesCountByUser, + ).not.toHaveBeenCalled(); + }); + }); }); describe('addOldAttributes', () => { @@ -2995,128 +3423,6 @@ describe('FileUseCases', () => { }); }); - describe('applyRetentionPolicy', () => { - const userUuid = 'user-uuid'; - const premiumLimits = { - enabled: true, - maxFileSize: 100 * 1024 * 1024, - retentionDays: 30, - maxVersions: 10, - }; - - it('When versioning is disabled, then it returns early', async () => { - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue({ ...premiumLimits, enabled: false }); - - const findAllByFileIdSpy = jest.spyOn( - fileVersionRepository, - 'findAllByFileId', - ); - - await service['applyRetentionPolicy']('file-uuid', userUuid); - - expect(findAllByFileIdSpy).not.toHaveBeenCalled(); - }); - - it('When no versions exist, then no versions are deleted', async () => { - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue(premiumLimits); - - jest - .spyOn(fileVersionRepository, 'findAllByFileId') - .mockResolvedValue([]); - - const updateStatusBatchSpy = jest - .spyOn(fileVersionRepository, 'updateStatusBatch') - .mockResolvedValue(undefined); - - await service['applyRetentionPolicy']('file-uuid', userUuid); - - expect(updateStatusBatchSpy).not.toHaveBeenCalled(); - }); - - it('When versions exist within retention period and under limit, then no versions are deleted', async () => { - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue(premiumLimits); - - const mockVersions = [ - { - id: '1', - createdAt: new Date(), - status: 'EXISTS', - }, - { - id: '2', - createdAt: new Date(), - status: 'EXISTS', - }, - ]; - - jest - .spyOn(fileVersionRepository, 'findAllByFileId') - .mockResolvedValue(mockVersions as any); - - const updateStatusBatchSpy = jest - .spyOn(fileVersionRepository, 'updateStatusBatch') - .mockResolvedValue(undefined); - - await service['applyRetentionPolicy']('file-uuid', userUuid); - - expect(updateStatusBatchSpy).not.toHaveBeenCalled(); - }); - - it('When limit is reached with recent versions, then oldest is deleted', async () => { - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue(premiumLimits); - - const mockVersions = Array.from({ length: 10 }, (_, i) => ({ - id: `${i + 1}`, - createdAt: new Date(Date.now() - i * 1000), - status: 'EXISTS', - })); - - jest - .spyOn(fileVersionRepository, 'findAllByFileId') - .mockResolvedValue(mockVersions as any); - - const updateStatusBatchSpy = jest - .spyOn(fileVersionRepository, 'updateStatusBatch') - .mockResolvedValue(undefined); - - await service['applyRetentionPolicy']('file-uuid', userUuid); - - expect(updateStatusBatchSpy).toHaveBeenCalledWith(['10'], 'DELETED'); - }); - - it('When versions exceed limit, then excess versions are deleted', async () => { - jest - .spyOn(featureLimitService, 'getFileVersioningLimits') - .mockResolvedValue(premiumLimits); - - const now = new Date(); - const mockVersions = Array.from({ length: 12 }, (_, i) => ({ - id: `${i + 1}`, - createdAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), - status: 'EXISTS', - })); - - jest - .spyOn(fileVersionRepository, 'findAllByFileId') - .mockResolvedValue(mockVersions as any); - const updateStatusBatchSpy = jest - .spyOn(fileVersionRepository, 'updateStatusBatch') - .mockResolvedValue(undefined); - - await service['applyRetentionPolicy']('file-uuid', userUuid); - - expect(updateStatusBatchSpy).toHaveBeenCalled(); - }); - }); - describe('getVersioningLimits', () => { it('When called with a valid user id, then the versioning limits are returned', async () => { const userUuid = v4(); diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 06d0e2863..51503d847 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -48,9 +48,8 @@ import { PLAN_FREE_INDIVIDUAL_TIER_LABEL, LimitLabels, } from '../feature-limit/limits.enum'; -import { FeatureLimitUsecases } from '../feature-limit/feature-limit.usecase'; import { SequelizeFileVersionRepository } from './file-version.repository'; -import { FileVersion, FileVersionStatus } from './file-version.domain'; +import { FileVersionStatus } from './file-version.domain'; import { FileVersionDto } from './dto/responses/file-version.dto'; import { UserUseCases } from '../user/user.usecase'; import { RedisService } from '../../externals/redis/redis.service'; @@ -59,6 +58,12 @@ import { TrashItemType } from '../trash/trash.attributes'; import { TrashUseCases } from '../trash/trash.usecase'; import { CacheManagerService } from '../cache-manager/cache-manager.service'; import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; +import { + DeleteFileVersionAction, + GetFileVersionsAction, + CreateFileVersionAction, +} from './actions'; +import { Workspace } from '../workspaces/domains/workspaces.domain'; export enum VersionableFileExtension { PDF = 'pdf', @@ -88,11 +93,13 @@ export class FileUseCases { private readonly usageService: UsageService, private readonly mailerService: MailerService, private readonly featureLimitService: FeatureLimitService, - private readonly featureLimitUsecases: FeatureLimitUsecases, @Inject(forwardRef(() => UserUseCases)) private readonly userUsecases: UserUseCases, private readonly redisService: RedisService, private readonly cacheManagerService: CacheManagerService, + private readonly getFileVersionsAction: GetFileVersionsAction, + private readonly deleteFileVersionAction: DeleteFileVersionAction, + private readonly createFileVersionAction: CreateFileVersionAction, ) {} getByUuid(uuid: FileAttributes['uuid']): Promise { @@ -107,9 +114,12 @@ export class FileUseCases { } async getUserUsedStorage(user: User): Promise { - const usageCalculation = await this.getUserUsedStorageIncrementally(user); + const [filesUsage, versionsUsage] = await Promise.all([ + this.getUserUsedStorageIncrementally(user), + this.fileVersionRepository.sumExistingSizesByUser(user.uuid), + ]); - return usageCalculation || 0; + return (filesUsage || 0) + versionsUsage; } async getUserUsedStorageIncrementally(user: User): Promise { @@ -247,28 +257,7 @@ export class FileUseCases { user: User, fileUuid: string, ): Promise { - const file = await this.fileRepository.findByUuid(fileUuid, user.id, {}); - - if (!file) { - throw new NotFoundException('File not found'); - } - - const [versions, limits] = await Promise.all([ - this.fileVersionRepository.findAllByFileId(fileUuid), - this.featureLimitService.getFileVersioningLimits(user.uuid), - ]); - - const { retentionDays } = limits; - - return versions.map((version) => { - const expiresAt = new Date(version.createdAt); - expiresAt.setDate(expiresAt.getDate() + retentionDays); - - return { - ...version.toJSON(), - expiresAt, - }; - }); + return this.getFileVersionsAction.execute(user, fileUuid); } async deleteFileVersion( @@ -276,30 +265,7 @@ export class FileUseCases { fileUuid: string, versionId: string, ): Promise { - const file = await this.fileRepository.findByUuid(fileUuid, user.id, {}); - - if (!file) { - throw new NotFoundException('File not found'); - } - - if (!file.isOwnedBy(user)) { - throw new ForbiddenException('You do not own this file'); - } - - const version = await this.fileVersionRepository.findById(versionId); - - if (!version) { - throw new NotFoundException('Version not found'); - } - - if (version.fileId !== fileUuid) { - throw new BadRequestException('Version does not belong to this file'); - } - - await this.fileVersionRepository.updateStatus( - versionId, - FileVersionStatus.DELETED, - ); + return this.deleteFileVersionAction.execute(user, fileUuid, versionId); } async restoreFileVersion( @@ -368,7 +334,15 @@ export class FileUseCases { return this.fileRepository.findByUuids(uuids); } - async createFile(user: User, newFileDto: CreateFileDto, tier?) { + async createFile( + user: User, + newFileDto: CreateFileDto, + tier?, + workspaceOptions?: { + workspace: Workspace; + memberId: string; + }, + ) { const [hadFilesBeforeUpload, folder] = await Promise.all([ this.hasUploadedFiles(user), this.folderUsecases.getByUuid(newFileDto.folderUuid), @@ -400,10 +374,17 @@ export class FileUseCases { throw new ConflictException('File already exists'); } - const isFileEmpty = newFileDto.size === BigInt(0); + const isFileEmpty = BigInt(newFileDto.size) === BigInt(0); if (isFileEmpty) { - await this.checkEmptyFilesLimit(user); + if (workspaceOptions) { + await this.checkWorkspaceEmptyFilesLimit( + workspaceOptions.memberId, + workspaceOptions.workspace, + ); + } else { + await this.checkEmptyFilesLimit(user); + } } const newFileId = isFileEmpty ? null : newFileDto.fileId; @@ -484,6 +465,16 @@ export class FileUseCases { } } + async getZeroSizeFilesInWorkspaceByMember( + memberId: string, + workspaceId: string, + ) { + return this.fileRepository.getZeroSizeFilesCountInWorkspaceByMember( + memberId, + workspaceId, + ); + } + async updateFileMetaData( user: User, fileUuid: File['uuid'], @@ -873,6 +864,10 @@ export class FileUseCases { user: User, fileUuid: File['fileId'], newFileData: ReplaceFileDto, + workspaceOptions?: { + workspace: Workspace; + memberId: string; + }, ): Promise { const file = await this.fileRepository.findByUuid(fileUuid, user.id); @@ -887,7 +882,14 @@ export class FileUseCases { const isFileEmpty = newFileData.size === BigInt(0); if (isFileEmpty) { - await this.checkEmptyFilesLimit(user); + if (!workspaceOptions) { + await this.checkEmptyFilesLimit(user); + } else { + await this.checkWorkspaceEmptyFilesLimit( + workspaceOptions.memberId, + workspaceOptions.workspace, + ); + } } const newFileId = isFileEmpty ? null : newFileData.fileId; @@ -899,24 +901,15 @@ export class FileUseCases { ); if (shouldVersion) { - await this.applyRetentionPolicy(fileUuid, user.uuid); - - const { fileId, size, modificationTime } = newFileData; - - await Promise.all([ - this.fileVersionRepository.upsert({ - fileId: file.uuid, - networkFileId: file.fileId, - size: file.size, - status: FileVersionStatus.EXISTS, - }), - this.fileRepository.updateByUuidAndUserId(fileUuid, user.id, { - fileId: newFileId, - size, - updatedAt: new Date(), - ...(modificationTime ? { modificationTime } : null), - }), - ]); + const { size, modificationTime } = newFileData; + + await this.createFileVersionAction.execute( + user, + file, + newFileId, + size, + modificationTime, + ); return { ...file.toJSON(), @@ -971,6 +964,33 @@ export class FileUseCases { }; } + async checkWorkspaceEmptyFilesLimit(memberId: string, workspace: Workspace) { + const workspaceOwner = await this.userUsecases.findByUuid( + workspace.ownerId, + ); + + const [maxZeroSizeFilesLimit, zeroSizeFilesCount] = await Promise.all([ + this.featureLimitService.getUserLimitByLabel( + LimitLabels.MaxZeroSizeFiles, + workspaceOwner, + ), + this.fileRepository.getZeroSizeFilesCountInWorkspaceByMember( + memberId, + workspace.id, + ), + ]); + + if ( + maxZeroSizeFilesLimit.shouldLimitBeEnforced({ + currentCount: zeroSizeFilesCount, + }) + ) { + throw new BadRequestException( + 'You can not have more empty files in this workspace', + ); + } + } + async deleteUserTrashedFilesBatch( user: User, limit: number, @@ -1066,10 +1086,9 @@ export class FileUseCases { } decrypFileName(file: File): any { - const decryptedName = this.cryptoService.decryptName( - file.name, - file.folderId, - ); + const decryptedName = + file.plainName ?? + this.cryptoService.decryptName(file.name, file.folderId); if (decryptedName === '') { return File.build(file); @@ -1232,61 +1251,6 @@ export class FileUseCases { return { versionable: true, limits }; } - private async applyRetentionPolicy( - fileUuid: string, - userUuid: string, - ): Promise { - const limits = - await this.featureLimitService.getFileVersioningLimits(userUuid); - - if (!limits.enabled) { - return; - } - - const { retentionDays, maxVersions } = limits; - - const cutoffDate = Time.daysAgo(retentionDays); - - const versions = await this.fileVersionRepository.findAllByFileId(fileUuid); - - const versionsToDeleteByAge = versions.filter( - (version) => version.createdAt < cutoffDate, - ); - - const remainingVersions = versions - .filter((version) => version.createdAt >= cutoffDate) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - - const versionsToDeleteByCount = remainingVersions.slice(maxVersions); - - const versionsToDelete = [ - ...versionsToDeleteByAge, - ...versionsToDeleteByCount, - ]; - - const remainingCount = versions.length - versionsToDelete.length; - if (remainingCount >= maxVersions) { - const versionsNotDeleted = versions.filter( - (v) => !versionsToDelete.some((vd) => vd.id === v.id), - ); - const oldestVersion = versionsNotDeleted.sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), - )[0]; - - if (oldestVersion) { - versionsToDelete.push(oldestVersion); - } - } - - if (versionsToDelete.length > 0) { - const idsToDelete = versionsToDelete.map((v) => v.id); - await this.fileVersionRepository.updateStatusBatch( - idsToDelete, - FileVersionStatus.DELETED, - ); - } - } - async getVersioningLimits(userUuid: string): Promise<{ enabled: boolean; maxFileSize: number; diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index a099d402a..286e9190d 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -14,6 +14,7 @@ import { Post, Put, Query, + UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, @@ -66,6 +67,8 @@ import { ValidateUUIDPipe } from '../../common/pipes/validate-uuid.pipe'; import { GetFilesInFoldersDto } from './dto/get-files-in-folder.dto'; import { GetFoldersInFoldersDto } from './dto/get-folders-in-folder.dto'; import { GetFoldersQueryDto } from './dto/get-folders.dto'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CustomThrottle } from '../../guards/custom-endpoint-throttle.decorator'; export class BadRequestWrongFolderIdException extends BadRequestException { constructor() { @@ -84,6 +87,10 @@ export class FolderController { private readonly storageNotificationService: StorageNotificationService, ) {} + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + long: { ttl: 3600, limit: 30000 }, + }) @Post('/') @ApiOperation({ summary: 'Create Folder', @@ -479,6 +486,10 @@ export class FolderController { }; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 60 }, + }) @Get('/') @ApiOkResponse({ isArray: true, type: FolderDto }) async getFolders( @@ -760,6 +771,10 @@ export class FolderController { return folderDto; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 30 }, + }) @Get('/meta') async getFolderMetaByPath( @UserDecorator() user: User, @@ -777,6 +792,7 @@ export class FolderController { user, folderPath, ); + if (!folder) { throw new NotFoundException('Folder not found'); } diff --git a/src/modules/folder/folder.module.ts b/src/modules/folder/folder.module.ts index be045868b..54c9f0124 100644 --- a/src/modules/folder/folder.module.ts +++ b/src/modules/folder/folder.module.ts @@ -13,6 +13,8 @@ import { WorkspacesModule } from '../workspaces/workspaces.module'; import { NotificationModule } from '../../externals/notifications/notifications.module'; import { TrashModule } from '../trash/trash.module'; import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CacheManagerModule } from '../cache-manager/cache-manager.module'; @Module({ imports: [ @@ -25,9 +27,15 @@ import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; NotificationModule, forwardRef(() => TrashModule), FeatureLimitModule, + CacheManagerModule, ], controllers: [FolderController], - providers: [SequelizeFolderRepository, CryptoService, FolderUseCases], + providers: [ + SequelizeFolderRepository, + CryptoService, + FolderUseCases, + CustomEndpointThrottleGuard, + ], exports: [FolderUseCases, SequelizeFolderRepository], }) export class FolderModule {} diff --git a/src/modules/folder/folder.repository.ts b/src/modules/folder/folder.repository.ts index 6faf158c3..c58fc0226 100644 --- a/src/modules/folder/folder.repository.ts +++ b/src/modules/folder/folder.repository.ts @@ -1179,14 +1179,31 @@ export class SequelizeFolderRepository implements FolderRepository { path: string, rootFolderUuid: Folder['uuid'], ): Promise { - const [[folder]] = await this.folderModel.sequelize.query( - 'SELECT * FROM get_folder_by_path (:userId, :path, :rootFolderUuid)', - { - replacements: { userId, path, rootFolderUuid }, - }, - ); + try { + return await this.folderModel.sequelize.transaction( + async (transaction) => { + await this.folderModel.sequelize.query( + "SET LOCAL statement_timeout = '8s'", + { transaction }, + ); + + const [[folder]] = await this.folderModel.sequelize.query( + 'SELECT * FROM get_folder_by_path (:userId, :path, :rootFolderUuid)', + { + replacements: { userId, path, rootFolderUuid }, + transaction, + }, + ); - return (folder as Folder) ?? null; + return (folder as Folder) ?? null; + }, + ); + } catch (error) { + if (error.original?.code === '57014') { + throw new Error('Query timed out'); + } + throw error; + } } private toDomain(model: FolderModel): Folder { diff --git a/src/modules/folder/folder.usecase.spec.ts b/src/modules/folder/folder.usecase.spec.ts index d71dbc979..9b9cf3867 100644 --- a/src/modules/folder/folder.usecase.spec.ts +++ b/src/modules/folder/folder.usecase.spec.ts @@ -9,6 +9,7 @@ import { NotAcceptableException, NotFoundException, UnprocessableEntityException, + RequestTimeoutException, } from '@nestjs/common'; import { v4 } from 'uuid'; import { Folder, FolderOptions } from './folder.domain'; @@ -461,6 +462,7 @@ describe('FolderUseCases', () => { const folder = newFolder({ attributes: { name: 'not encrypted name', + plainName: null, }, }); @@ -1407,6 +1409,29 @@ describe('FolderUseCases', () => { service.getFolderMetadataByPath(userMocked, folderPath), ).rejects.toThrow(NotFoundException); }); + + it('When get folder metadata by path times out, then it should throw RequestTimeoutException', async () => { + const folderPath = '/folder1/folder2'; + jest.spyOn(service, 'getFolderByUserId').mockResolvedValue(newFolder()); + jest + .spyOn(folderRepository, 'getFolderByPath') + .mockRejectedValue(new Error('Query timed out')); + + await expect( + service.getFolderMetadataByPath(userMocked, folderPath), + ).rejects.toThrow(RequestTimeoutException); + }); + + it('When get folder metadata by path throws generic error, then it should rethrow it', async () => { + const folderPath = '/folder1/folder2'; + const error = new Error('Generic error'); + jest.spyOn(service, 'getFolderByUserId').mockResolvedValue(newFolder()); + jest.spyOn(folderRepository, 'getFolderByPath').mockRejectedValue(error); + + await expect( + service.getFolderMetadataByPath(userMocked, folderPath), + ).rejects.toThrow(error); + }); }); describe('getWorkspacesFoldersUpdatedAfter', () => { @@ -1678,7 +1703,9 @@ describe('FolderUseCases', () => { const folderUuid = v4(); it('When folder exists, then it should decrypt and return the folder', async () => { - const folder = newFolder({ attributes: { uuid: folderUuid } }); + const folder = newFolder({ + attributes: { uuid: folderUuid, plainName: null }, + }); const decryptedName = 'Decrypted Name'; jest.spyOn(folderRepository, 'findByUuid').mockResolvedValueOnce(folder); @@ -1699,6 +1726,24 @@ describe('FolderUseCases', () => { expect(result.plainName).toBe(decryptedName); }); + it('When the folder has a plain name, then the plain name is returned', async () => { + const folder = newFolder({ + attributes: { uuid: folderUuid, plainName: 'plain name' }, + }); + + jest.spyOn(folderRepository, 'findByUuid').mockResolvedValueOnce(folder); + const decryptSpy = jest.spyOn(cryptoService, 'decryptName'); + + const result = await service.getByUuid(folderUuid); + + expect(folderRepository.findByUuid).toHaveBeenCalledWith( + folderUuid, + false, + ); + expect(decryptSpy).not.toHaveBeenCalled(); + expect(result.plainName).toBe('plain name'); + }); + it('When folder does not exist, then it should throw NotFoundException', async () => { jest.spyOn(folderRepository, 'findByUuid').mockResolvedValueOnce(null); diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index faa256c90..9e0c2023f 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -7,6 +7,7 @@ import { Logger, NotAcceptableException, NotFoundException, + RequestTimeoutException, UnprocessableEntityException, forwardRef, } from '@nestjs/common'; @@ -69,10 +70,9 @@ export class FolderUseCases { throw new NotFoundException('Folder not found'); } - folder.plainName = this.cryptoService.decryptName( - folder.name, - folder.parentId, - ); + folder.plainName = + folder.plainName ?? + this.cryptoService.decryptName(folder.name, folder.parentId); return folder; } @@ -964,10 +964,9 @@ export class FolderUseCases { } decryptFolderName(folder: Folder): Folder { - const decryptedName = this.cryptoService.decryptName( - folder.name, - folder.parentId, - ); + const decryptedName = + folder.plainName ?? + this.cryptoService.decryptName(folder.name, folder.parentId); if (decryptedName === '') { throw new Error('Unable to decrypt folder name'); @@ -1029,11 +1028,18 @@ export class FolderUseCases { throw new NotFoundException('Root Folder not found'); } - return this.folderRepository.getFolderByPath( - user.id, - path, - rootFolder.uuid, - ); + try { + return await this.folderRepository.getFolderByPath( + user.id, + path, + rootFolder.uuid, + ); + } catch (error) { + if (error.message === 'Query timed out') { + throw new RequestTimeoutException('Folder metadata search timed out'); + } + throw error; + } } async updateByFolderIdAndForceUpdatedAt( diff --git a/src/modules/gateway/constants.ts b/src/modules/gateway/constants.ts index dfa62df1a..439a0ead2 100644 --- a/src/modules/gateway/constants.ts +++ b/src/modules/gateway/constants.ts @@ -2,4 +2,5 @@ import { LimitLabels } from '../feature-limit/limits.enum'; export const FeatureNameLimitMap: Record = { cli: LimitLabels.CliAccess, + rclone: LimitLabels.RcloneAccess, }; diff --git a/src/modules/sharing/sharing.module.ts b/src/modules/sharing/sharing.module.ts index 869621b51..96e3dbf99 100644 --- a/src/modules/sharing/sharing.module.ts +++ b/src/modules/sharing/sharing.module.ts @@ -50,7 +50,7 @@ import { CaptchaService } from '../../externals/captcha/captcha.service'; SequelizeSharingRepository, SequelizeUserReferralsRepository, PaymentsService, - CaptchaService + CaptchaService, ], exports: [SharingService, SequelizeSharingRepository, SequelizeModule], }) diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index da1b6e4ad..bfe16a0d6 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -108,6 +108,8 @@ import { PaymentRequiredException } from '../feature-limit/exceptions/payment-re import { FeatureLimitService } from '../feature-limit/feature-limit.service'; import { KlaviyoTrackingService } from '../../externals/klaviyo/klaviyo-tracking.service'; import { CaptchaGuard } from '../auth/captcha.guard'; +import { CustomEndpointThrottleGuard } from '../../guards/custom-endpoint-throttle.guard'; +import { CustomThrottle } from '../../guards/custom-endpoint-throttle.decorator'; @ApiTags('User') @Controller('users') @@ -460,6 +462,10 @@ export class UserController { return userCredentials; } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 5 }, + }) @Get('/refresh') @HttpCode(200) @ApiOperation({ summary: 'Refresh session token' }) @@ -971,8 +977,8 @@ export class UserController { @Throttle({ default: { ttl: 60, - limit: 5 - } + limit: 5, + }, }) @Put('/public-key/:email') @UseGuards(CaptchaGuard) @@ -1257,6 +1263,10 @@ export class UserController { } } + @UseGuards(CustomEndpointThrottleGuard) + @CustomThrottle({ + short: { ttl: 60, limit: 60 }, + }) @Get('/usage') @ApiBearerAuth() @ApiOperation({ diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index aa974a8b5..aa05bdae4 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -102,7 +102,7 @@ import { CaptchaService } from '../../externals/captcha/captcha.service'; NewsletterService, AvatarService, MailerService, - CaptchaService + CaptchaService, ], exports: [ UserUseCases, diff --git a/src/modules/workspaces/workspaces.module.ts b/src/modules/workspaces/workspaces.module.ts index 3fc790b9c..1603a4fe5 100644 --- a/src/modules/workspaces/workspaces.module.ts +++ b/src/modules/workspaces/workspaces.module.ts @@ -25,6 +25,7 @@ import { FuzzySearchUseCases } from '../fuzzy-search/fuzzy-search.usecase'; import { FuzzySearchModule } from '../fuzzy-search/fuzzy-search.module'; import { NotificationModule } from '../../externals/notifications/notifications.module'; import { WorkspaceLogModel } from './models/workspace-logs.model'; +import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; @Module({ imports: [ @@ -47,6 +48,7 @@ import { WorkspaceLogModel } from './models/workspace-logs.model'; HttpClientModule, FuzzySearchModule, NotificationModule, + forwardRef(() => FeatureLimitModule), ], controllers: [WorkspacesController], providers: [ diff --git a/src/modules/workspaces/workspaces.usecase.spec.ts b/src/modules/workspaces/workspaces.usecase.spec.ts index 1a36471db..612b32da1 100644 --- a/src/modules/workspaces/workspaces.usecase.spec.ts +++ b/src/modules/workspaces/workspaces.usecase.spec.ts @@ -2688,6 +2688,195 @@ describe('WorkspacesUsecases', () => { expect(result).toEqual({ ...createdFile, item: createdItemFile }); }); + + it('When creating empty file in workspace and limit not reached, then it should succeed', async () => { + const user = newUser(); + const workspace = newWorkspace(); + const workspaceUser = newWorkspaceUser({ + attributes: { + spaceLimit: 10240, + driveUsage: 0, + rootFolderId: 'root-folder-uuid', + }, + workspaceId: workspace.id, + member: user, + }); + const folderItem = newWorkspaceItemUser({ createdBy: user.uuid }); + const createdFile = newFile({ owner: user }); + const createdItemFile = newWorkspaceItemUser({ + createdBy: user.uuid, + itemType: WorkspaceItemType.File, + }); + const emptyFileDto = { + ...createFileDto, + size: BigInt(0), + }; + + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(workspaceUser); + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); + jest + .spyOn(workspaceRepository, 'getItemBy') + .mockResolvedValue(folderItem); + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValue({} as any); + jest.spyOn(fileUseCases, 'createFile').mockResolvedValue(createdFile); + jest + .spyOn(workspaceRepository, 'createItem') + .mockResolvedValue(createdItemFile); + + const result = await service.createFile(user, workspace.id, emptyFileDto); + + expect(fileUseCases.createFile).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ size: BigInt(0) }), + undefined, + { + workspace, + memberId: workspaceUser.memberId, + }, + ); + expect(result).toEqual({ ...createdFile, item: createdItemFile }); + }); + + it('When creating empty file in workspace and limit reached, then it should throw', async () => { + const user = newUser(); + const workspace = newWorkspace(); + const workspaceUser = newWorkspaceUser({ + attributes: { + spaceLimit: 10240, + driveUsage: 0, + }, + workspaceId: workspace.id, + member: user, + }); + const folderItem = newWorkspaceItemUser({ createdBy: user.uuid }); + const emptyFileDto = { + ...createFileDto, + size: BigInt(0), + }; + + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(workspaceUser); + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); + jest + .spyOn(workspaceRepository, 'getItemBy') + .mockResolvedValue(folderItem); + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValue({} as any); + jest + .spyOn(fileUseCases, 'createFile') + .mockRejectedValue( + new BadRequestException( + 'You can not have more empty files in this workspace', + ), + ); + + await expect( + service.createFile(user, workspace.id, emptyFileDto), + ).rejects.toThrow(BadRequestException); + }); + + it('When creating empty file in workspace, then it should pass workspace options to fileUseCases.createFile', async () => { + const user = newUser(); + const fileSize = 0; + const workspace = newWorkspace(); + const workspaceUser = newWorkspaceUser({ + attributes: { + spaceLimit: 10240, + driveUsage: 0, + rootFolderId: createFileDto.folderUuid, + }, + workspaceId: workspace.id, + member: user, + }); + const folderItem = newWorkspaceItemUser({ createdBy: user.uuid }); + const createdFile = newFile({ owner: user }); + const createdItemFile = newWorkspaceItemUser({ + createdBy: user.uuid, + itemType: WorkspaceItemType.File, + }); + + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(workspaceUser); + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); + jest + .spyOn(workspaceRepository, 'getItemBy') + .mockResolvedValue(folderItem); + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValue({} as any); + const createFileSpy = jest + .spyOn(fileUseCases, 'createFile') + .mockResolvedValue(createdFile); + jest + .spyOn(workspaceRepository, 'createItem') + .mockResolvedValue(createdItemFile); + + await service.createFile(user, workspace.id, { + ...createFileDto, + size: BigInt(fileSize), + }); + + expect(createFileSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ size: BigInt(fileSize) }), + undefined, + { + workspace, + memberId: workspaceUser.memberId, + }, + ); + }); + + it('When creating non-empty file in workspace, then it should pass workspace options to fileUseCases.createFile', async () => { + const user = newUser(); + const fileSize = 2000; + const workspace = newWorkspace(); + const workspaceUser = newWorkspaceUser({ + attributes: { + spaceLimit: fileSize + 1, + rootFolderId: createFileDto.folderUuid, + }, + workspaceId: workspace.id, + member: user, + }); + const folderItem = newWorkspaceItemUser({ createdBy: user.uuid }); + const createdFile = newFile({ owner: user }); + const createdItemFile = newWorkspaceItemUser({ + createdBy: user.uuid, + itemType: WorkspaceItemType.File, + }); + + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(workspaceUser); + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); + jest + .spyOn(workspaceRepository, 'getItemBy') + .mockResolvedValue(folderItem); + jest.spyOn(folderUseCases, 'getByUuid').mockResolvedValue({} as any); + const createFileSpy = jest + .spyOn(fileUseCases, 'createFile') + .mockResolvedValue(createdFile); + jest + .spyOn(workspaceRepository, 'createItem') + .mockResolvedValue(createdItemFile); + + await service.createFile(user, workspace.id, { + ...createFileDto, + size: BigInt(fileSize), + }); + + expect(createFileSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ size: BigInt(fileSize) }), + undefined, + { + workspace, + memberId: workspaceUser.memberId, + }, + ); + }); }); describe('getPersonalWorkspaceFoldersInFolder', () => { diff --git a/src/modules/workspaces/workspaces.usecase.ts b/src/modules/workspaces/workspaces.usecase.ts index df902baed..6120decb1 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -77,6 +77,7 @@ import { SharingAccessTokenData } from '../sharing/guards/sharings-token.interfa import { FuzzySearchUseCases } from '../fuzzy-search/fuzzy-search.usecase'; import { WorkspaceLog } from './domains/workspace-log.domain'; import { TrashItem } from './interceptors/workspaces-logs.interceptor'; +import { FeatureLimitService } from '../feature-limit/feature-limit.service'; @Injectable() export class WorkspacesUsecases { @@ -98,6 +99,7 @@ export class WorkspacesUsecases { private readonly folderUseCases: FolderUseCases, private readonly avatarService: AvatarService, private readonly fuzzySearchUseCases: FuzzySearchUseCases, + private readonly featureLimitsService: FeatureLimitService, ) {} async initiateWorkspace( @@ -910,6 +912,10 @@ export class WorkspacesUsecases { ...createFileDto, }, tier, + { + workspace, + memberId: workspaceUser.memberId, + }, ); const createdItemFile = await this.workspaceRepository.createItem({ diff --git a/test/fixtures.ts b/test/fixtures.ts index 3c8bb042f..544da6006 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -762,6 +762,7 @@ export const newFileVersion = (params?: { const fileVersion = FileVersion.build({ id: v4(), fileId: v4(), + userId: v4(), networkFileId: randomDataGenerator.hash({ length: constants.BUCKET_ID_LENGTH, }), diff --git a/yarn.lock b/yarn.lock index 8551bb56e..b0773389a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -692,7 +692,16 @@ resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.1.tgz#ceecff9ebe1f6199369e6911f38633fac3322811" integrity sha512-sIyFcoPZkTtNu9xFeEoynMef3bPJIAbOfUh+ueYcfhVl6xm2VRtMcMclSxmZCMnHHd4hlYKJeq/aggmBEWynww== -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.27.1": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1", "@babel/code-frame@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.28.6.tgz#72499312ec58b1e2245ba4a4f550c132be4982f7" + integrity sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/code-frame@^7.16.7": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== @@ -701,25 +710,25 @@ js-tokens "^4.0.0" picocolors "^1.1.1" -"@babel/compat-data@^7.27.2": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.5.tgz#a8a4962e1567121ac0b3b487f52107443b455c7f" - integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== - -"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.5.tgz#4c81b35e51e1b734f510c99b07dfbc7bbbb48f7e" - integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== - dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.28.5" - "@babel/helper-compilation-targets" "^7.27.2" - "@babel/helper-module-transforms" "^7.28.3" - "@babel/helpers" "^7.28.4" - "@babel/parser" "^7.28.5" - "@babel/template" "^7.27.2" - "@babel/traverse" "^7.28.5" - "@babel/types" "^7.28.5" +"@babel/compat-data@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.6.tgz#103f466803fa0f059e82ccac271475470570d74c" + integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg== + +"@babel/core@^7.23.9", "@babel/core@^7.27.4": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.6.tgz#531bf883a1126e53501ba46eb3bb414047af507f" + integrity sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/generator" "^7.28.6" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helpers" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" "@jridgewell/remapping" "^2.3.5" convert-source-map "^2.0.0" debug "^4.1.0" @@ -727,23 +736,23 @@ json5 "^2.2.3" semver "^6.3.1" -"@babel/generator@^7.28.5", "@babel/generator@^7.7.2": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298" - integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== +"@babel/generator@^7.27.5", "@babel/generator@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.6.tgz#48dcc65d98fcc8626a48f72b62e263d25fc3c3f1" + integrity sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw== dependencies: - "@babel/parser" "^7.28.5" - "@babel/types" "^7.28.5" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" "@jridgewell/gen-mapping" "^0.3.12" "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" -"@babel/helper-compilation-targets@^7.27.2": - version "7.27.2" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" - integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== +"@babel/helper-compilation-targets@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25" + integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== dependencies: - "@babel/compat-data" "^7.27.2" + "@babel/compat-data" "^7.28.6" "@babel/helper-validator-option" "^7.27.1" browserslist "^4.24.0" lru-cache "^5.1.1" @@ -754,27 +763,27 @@ resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== -"@babel/helper-module-imports@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" - integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== +"@babel/helper-module-imports@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c" + integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== dependencies: - "@babel/traverse" "^7.27.1" - "@babel/types" "^7.27.1" + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" -"@babel/helper-module-transforms@^7.28.3": - version "7.28.3" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz#a2b37d3da3b2344fe085dab234426f2b9a2fa5f6" - integrity sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw== +"@babel/helper-module-transforms@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" + integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== dependencies: - "@babel/helper-module-imports" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" - "@babel/traverse" "^7.28.3" + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.6" -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.8.0": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" - integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.28.6", "@babel/helper-plugin-utils@^7.8.0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== "@babel/helper-string-parser@^7.27.1": version "7.27.1" @@ -791,20 +800,20 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== -"@babel/helpers@^7.28.4": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.4.tgz#fe07274742e95bdf7cf1443593eeb8926ab63827" - integrity sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w== +"@babel/helpers@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" + integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== dependencies: - "@babel/template" "^7.27.2" - "@babel/types" "^7.28.4" + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.27.2", "@babel/parser@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" - integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.6.tgz#f01a8885b7fa1e56dd8a155130226cd698ef13fd" + integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ== dependencies: - "@babel/types" "^7.28.5" + "@babel/types" "^7.28.6" "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" @@ -835,11 +844,11 @@ "@babel/helper-plugin-utils" "^7.14.5" "@babel/plugin-syntax-import-attributes@^7.24.7": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz#34c017d54496f9b11b61474e7ea3dfd5563ffe07" - integrity sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww== + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz#b71d5914665f60124e133696f17cd7669062c503" + integrity sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw== dependencies: - "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-syntax-import-meta@^7.10.4": version "7.10.4" @@ -855,12 +864,12 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-jsx@^7.7.2": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz#2f9beb5eff30fa507c5532d107daac7b888fa34c" - integrity sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w== +"@babel/plugin-syntax-jsx@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz#f8ca28bbd84883b5fea0e447c635b81ba73997ee" + integrity sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w== dependencies: - "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-syntax-logical-assignment-operators@^7.10.4": version "7.10.4" @@ -918,39 +927,39 @@ dependencies: "@babel/helper-plugin-utils" "^7.14.5" -"@babel/plugin-syntax-typescript@^7.7.2": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18" - integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ== +"@babel/plugin-syntax-typescript@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz#c7b2ddf1d0a811145b1de800d1abd146af92e3a2" + integrity sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A== dependencies: - "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/template@^7.27.2", "@babel/template@^7.3.3": - version "7.27.2" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" - integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== +"@babel/template@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/parser" "^7.27.2" - "@babel/types" "^7.27.1" + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" -"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.5.tgz#450cab9135d21a7a2ca9d2d35aa05c20e68c360b" - integrity sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ== +"@babel/traverse@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.6.tgz#871ddc79a80599a5030c53b1cc48cbe3a5583c2e" + integrity sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg== dependencies: - "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.28.5" + "@babel/code-frame" "^7.28.6" + "@babel/generator" "^7.28.6" "@babel/helper-globals" "^7.28.0" - "@babel/parser" "^7.28.5" - "@babel/template" "^7.27.2" - "@babel/types" "^7.28.5" + "@babel/parser" "^7.28.6" + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" debug "^4.3.1" -"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.28.5", "@babel/types@^7.3.3": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" - integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.6.tgz#c3e9377f1b155005bcc4c46020e7e394e13089df" + integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg== dependencies: "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" @@ -996,6 +1005,28 @@ resolved "https://registry.yarnpkg.com/@dashlane/pqc-kem-kyber512-node/-/pqc-kem-kyber512-node-1.0.0.tgz#0305f8a6c86595a1dc3b0d16184237c71e912d8c" integrity sha512-gVzQwP/1OqKLyYZ/oRI9uECSnYIcLUcZbnAA34Q2l8X1eXq5JWf304tDp1UTdYdJ+ZE58SmQ68VCa/WvpCviGw== +"@emnapi/core@^1.4.3": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" + integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg== + dependencies: + "@emnapi/wasi-threads" "1.1.0" + tslib "^2.4.0" + +"@emnapi/runtime@^1.4.3": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5" + integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" + integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== + dependencies: + tslib "^2.4.0" + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.9.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" @@ -1033,10 +1064,10 @@ resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA== -"@golevelup/ts-jest@^0.6.2": - version "0.6.2" - resolved "https://registry.yarnpkg.com/@golevelup/ts-jest/-/ts-jest-0.6.2.tgz#483a482e1ab5a835cdd0f8669f76d1201c4a0f63" - integrity sha512-ks82vcWbnRuwHSKlrZTGCPPWXZEKlsn1VA2OiYfJ+tVMcMsI4y9ExWkf7FnmYypYJIRWKS9b9N5QVVrCOmaVlg== +"@golevelup/ts-jest@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@golevelup/ts-jest/-/ts-jest-1.2.0.tgz#986c514080bd9960e6a9ec19d2f0e46c48d1bdc4" + integrity sha512-plN26rWBmwOrtWBc46FKgTMWDlLK5F/vYP2i+0NXnoOLdcXrTNDftzW4dpa0fREqUdCRb3EJtuyr6g5AKNKTRA== "@grpc/grpc-js@^1.13.2": version "1.14.2" @@ -1333,197 +1364,225 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/console@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.7.0.tgz#cd4822dbdb84529265c5a2bdb529a3c9cc950ffc" - integrity sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg== +"@jest/console@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-30.2.0.tgz#c52fcd5b58fdd2e8eb66b2fd8ae56f2f64d05b28" + integrity sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ== dependencies: - "@jest/types" "^29.6.3" + "@jest/types" "30.2.0" "@types/node" "*" - chalk "^4.0.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" + chalk "^4.1.2" + jest-message-util "30.2.0" + jest-util "30.2.0" slash "^3.0.0" -"@jest/core@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.7.0.tgz#b6cccc239f30ff36609658c5a5e2291757ce448f" - integrity sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg== - dependencies: - "@jest/console" "^29.7.0" - "@jest/reporters" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" +"@jest/core@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-30.2.0.tgz#813d59faa5abd5510964a8b3a7b17cc77b775275" + integrity sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ== + dependencies: + "@jest/console" "30.2.0" + "@jest/pattern" "30.0.1" + "@jest/reporters" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" - ci-info "^3.2.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - jest-changed-files "^29.7.0" - jest-config "^29.7.0" - jest-haste-map "^29.7.0" - jest-message-util "^29.7.0" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-resolve-dependencies "^29.7.0" - jest-runner "^29.7.0" - jest-runtime "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - jest-watcher "^29.7.0" - micromatch "^4.0.4" - pretty-format "^29.7.0" + ansi-escapes "^4.3.2" + chalk "^4.1.2" + ci-info "^4.2.0" + exit-x "^0.2.2" + graceful-fs "^4.2.11" + jest-changed-files "30.2.0" + jest-config "30.2.0" + jest-haste-map "30.2.0" + jest-message-util "30.2.0" + jest-regex-util "30.0.1" + jest-resolve "30.2.0" + jest-resolve-dependencies "30.2.0" + jest-runner "30.2.0" + jest-runtime "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" + jest-watcher "30.2.0" + micromatch "^4.0.8" + pretty-format "30.2.0" slash "^3.0.0" - strip-ansi "^6.0.0" -"@jest/environment@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.7.0.tgz#24d61f54ff1f786f3cd4073b4b94416383baf2a7" - integrity sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== +"@jest/diff-sequences@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz#0ededeae4d071f5c8ffe3678d15f3a1be09156be" + integrity sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw== + +"@jest/environment@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-30.2.0.tgz#1e673cdb8b93ded707cf6631b8353011460831fa" + integrity sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g== dependencies: - "@jest/fake-timers" "^29.7.0" - "@jest/types" "^29.6.3" + "@jest/fake-timers" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - jest-mock "^29.7.0" + jest-mock "30.2.0" -"@jest/expect-utils@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" - integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== +"@jest/expect-utils@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.2.0.tgz#4f95413d4748454fdb17404bf1141827d15e6011" + integrity sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA== dependencies: - jest-get-type "^29.6.3" + "@jest/get-type" "30.1.0" -"@jest/expect@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.7.0.tgz#76a3edb0cb753b70dfbfe23283510d3d45432bf2" - integrity sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ== +"@jest/expect@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-30.2.0.tgz#9a5968499bb8add2bbb09136f69f7df5ddbf3185" + integrity sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA== dependencies: - expect "^29.7.0" - jest-snapshot "^29.7.0" + expect "30.2.0" + jest-snapshot "30.2.0" -"@jest/fake-timers@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz#fd91bf1fffb16d7d0d24a426ab1a47a49881a565" - integrity sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== +"@jest/fake-timers@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-30.2.0.tgz#0941ddc28a339b9819542495b5408622dc9e94ec" + integrity sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw== dependencies: - "@jest/types" "^29.6.3" - "@sinonjs/fake-timers" "^10.0.2" + "@jest/types" "30.2.0" + "@sinonjs/fake-timers" "^13.0.0" "@types/node" "*" - jest-message-util "^29.7.0" - jest-mock "^29.7.0" - jest-util "^29.7.0" - -"@jest/globals@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.7.0.tgz#8d9290f9ec47ff772607fa864ca1d5a2efae1d4d" - integrity sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ== + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-util "30.2.0" + +"@jest/get-type@30.1.0": + version "30.1.0" + resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.1.0.tgz#4fcb4dc2ebcf0811be1c04fd1cb79c2dba431cbc" + integrity sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA== + +"@jest/globals@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-30.2.0.tgz#2f4b696d5862664b89c4ee2e49ae24d2bb7e0988" + integrity sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw== + dependencies: + "@jest/environment" "30.2.0" + "@jest/expect" "30.2.0" + "@jest/types" "30.2.0" + jest-mock "30.2.0" + +"@jest/pattern@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.0.1.tgz#d5304147f49a052900b4b853dedb111d080e199f" + integrity sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA== dependencies: - "@jest/environment" "^29.7.0" - "@jest/expect" "^29.7.0" - "@jest/types" "^29.6.3" - jest-mock "^29.7.0" + "@types/node" "*" + jest-regex-util "30.0.1" -"@jest/reporters@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.7.0.tgz#04b262ecb3b8faa83b0b3d321623972393e8f4c7" - integrity sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg== +"@jest/reporters@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-30.2.0.tgz#a36b28fcbaf0c4595250b108e6f20e363348fd91" + integrity sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" - "@jridgewell/trace-mapping" "^0.3.18" + "@jest/console" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" + "@jridgewell/trace-mapping" "^0.3.25" "@types/node" "*" - chalk "^4.0.0" - collect-v8-coverage "^1.0.0" - exit "^0.1.2" - glob "^7.1.3" - graceful-fs "^4.2.9" + chalk "^4.1.2" + collect-v8-coverage "^1.0.2" + exit-x "^0.2.2" + glob "^10.3.10" + graceful-fs "^4.2.11" istanbul-lib-coverage "^3.0.0" istanbul-lib-instrument "^6.0.0" istanbul-lib-report "^3.0.0" - istanbul-lib-source-maps "^4.0.0" + istanbul-lib-source-maps "^5.0.0" istanbul-reports "^3.1.3" - jest-message-util "^29.7.0" - jest-util "^29.7.0" - jest-worker "^29.7.0" + jest-message-util "30.2.0" + jest-util "30.2.0" + jest-worker "30.2.0" slash "^3.0.0" - string-length "^4.0.1" - strip-ansi "^6.0.0" + string-length "^4.0.2" v8-to-istanbul "^9.0.1" -"@jest/schemas@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" - integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== +"@jest/schemas@30.0.5": + version "30.0.5" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.0.5.tgz#7bdf69fc5a368a5abdb49fd91036c55225846473" + integrity sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA== dependencies: - "@sinclair/typebox" "^0.27.8" + "@sinclair/typebox" "^0.34.0" -"@jest/source-map@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.6.3.tgz#d90ba772095cf37a34a5eb9413f1b562a08554c4" - integrity sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw== +"@jest/snapshot-utils@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz#387858eb90c2f98f67bff327435a532ac5309fbe" + integrity sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug== dependencies: - "@jridgewell/trace-mapping" "^0.3.18" - callsites "^3.0.0" - graceful-fs "^4.2.9" - -"@jest/test-result@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.7.0.tgz#8db9a80aa1a097bb2262572686734baed9b1657c" - integrity sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA== - dependencies: - "@jest/console" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/istanbul-lib-coverage" "^2.0.0" - collect-v8-coverage "^1.0.0" - -"@jest/test-sequencer@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz#6cef977ce1d39834a3aea887a1726628a6f072ce" - integrity sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw== - dependencies: - "@jest/test-result" "^29.7.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" + "@jest/types" "30.2.0" + chalk "^4.1.2" + graceful-fs "^4.2.11" + natural-compare "^1.4.0" + +"@jest/source-map@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-30.0.1.tgz#305ebec50468f13e658b3d5c26f85107a5620aaa" + integrity sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + callsites "^3.1.0" + graceful-fs "^4.2.11" + +"@jest/test-result@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-30.2.0.tgz#9c0124377fb7996cdffb86eda3dbc56eacab363d" + integrity sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg== + dependencies: + "@jest/console" "30.2.0" + "@jest/types" "30.2.0" + "@types/istanbul-lib-coverage" "^2.0.6" + collect-v8-coverage "^1.0.2" + +"@jest/test-sequencer@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz#bf0066bc72e176d58f5dfa7f212b6e7eee44f221" + integrity sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q== + dependencies: + "@jest/test-result" "30.2.0" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" slash "^3.0.0" -"@jest/transform@^29.7.0": - version "29.7.0" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.7.0.tgz#df2dd9c346c7d7768b8a06639994640c642e284c" - integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== +"@jest/transform@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-30.2.0.tgz#54bef1a4510dcbd58d5d4de4fe2980a63077ef2a" + integrity sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA== dependencies: - "@babel/core" "^7.11.6" - "@jest/types" "^29.6.3" - "@jridgewell/trace-mapping" "^0.3.18" - babel-plugin-istanbul "^6.1.1" - chalk "^4.0.0" + "@babel/core" "^7.27.4" + "@jest/types" "30.2.0" + "@jridgewell/trace-mapping" "^0.3.25" + babel-plugin-istanbul "^7.0.1" + chalk "^4.1.2" convert-source-map "^2.0.0" fast-json-stable-stringify "^2.1.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-regex-util "^29.6.3" - jest-util "^29.7.0" - micromatch "^4.0.4" - pirates "^4.0.4" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" + jest-regex-util "30.0.1" + jest-util "30.2.0" + micromatch "^4.0.8" + pirates "^4.0.7" slash "^3.0.0" - write-file-atomic "^4.0.2" + write-file-atomic "^5.0.1" -"@jest/types@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" - integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== +"@jest/types@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.2.0.tgz#1c678a7924b8f59eafd4c77d56b6d0ba976d62b8" + integrity sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg== dependencies: - "@jest/schemas" "^29.6.3" - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" + "@jest/pattern" "30.0.1" + "@jest/schemas" "30.0.5" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" "@types/node" "*" - "@types/yargs" "^17.0.8" - chalk "^4.0.0" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" "@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": version "0.3.13" @@ -1567,7 +1626,7 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": version "0.3.31" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== @@ -1604,6 +1663,15 @@ resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz#2249090633e04063176863a050c8f0808d2b6d2b" integrity sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA== +"@napi-rs/wasm-runtime@^0.2.11": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" + integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@tybys/wasm-util" "^0.10.0" + "@nest-lab/throttler-storage-redis@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@nest-lab/throttler-storage-redis/-/throttler-storage-redis-1.1.0.tgz#78f3dad83dbf6f890f27ce3f323b4a1e5f33e227" @@ -1858,7 +1926,7 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.4.0", "@opentelemetry/api@^1.9.0": +"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== @@ -2138,10 +2206,10 @@ "@sendgrid/client" "^8.1.5" "@sendgrid/helpers" "^8.0.0" -"@sinclair/typebox@^0.27.8": - version "0.27.8" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" - integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== +"@sinclair/typebox@^0.34.0": + version "0.34.48" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.48.tgz#75b0ead87e59e1adbd6dccdc42bad4fddee73b59" + integrity sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA== "@sinonjs/commons@^3.0.0", "@sinonjs/commons@^3.0.1": version "3.0.1" @@ -2157,14 +2225,7 @@ dependencies: "@sinonjs/commons" "^3.0.0" -"@sinonjs/fake-timers@^10.0.2": - version "10.3.0" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" - integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== - dependencies: - "@sinonjs/commons" "^3.0.0" - -"@sinonjs/fake-timers@^13.0.1": +"@sinonjs/fake-timers@^13.0.0", "@sinonjs/fake-timers@^13.0.1": version "13.0.5" resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz#36b9dbc21ad5546486ea9173d6bea063eb1717d5" integrity sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw== @@ -2726,7 +2787,14 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== -"@types/babel__core@^7.1.14": +"@tybys/wasm-util@^0.10.0": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + +"@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== @@ -2752,7 +2820,7 @@ "@babel/parser" "^7.1.0" "@babel/types" "^7.0.0" -"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": +"@types/babel__traverse@*": version "7.28.0" resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74" integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== @@ -2848,19 +2916,12 @@ "@types/express-serve-static-core" "^5.0.0" "@types/serve-static" "^2" -"@types/graceful-fs@^4.1.3": - version "4.1.9" - resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" - integrity sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ== - dependencies: - "@types/node" "*" - "@types/http-errors@*": version "2.0.5" resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472" integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== @@ -2872,20 +2933,20 @@ dependencies: "@types/istanbul-lib-coverage" "*" -"@types/istanbul-reports@^3.0.0": +"@types/istanbul-reports@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@29.5.14": - version "29.5.14" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.14.tgz#2b910912fa1d6856cadcd0c1f95af7df1d6049e5" - integrity sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ== +"@types/jest@^30.0.0": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-30.0.0.tgz#5e85ae568006712e4ad66f25433e9bdac8801f1d" + integrity sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA== dependencies: - expect "^29.0.0" - pretty-format "^29.0.0" + expect "^30.0.0" + pretty-format "^30.0.0" "@types/json-schema@*", "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" @@ -2955,7 +3016,14 @@ dependencies: "@types/express" "*" -"@types/node@*", "@types/node@>=13.7.0", "@types/node@>=8.1.0": +"@types/node@*": + version "25.0.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.10.tgz#4864459c3c9459376b8b75fd051315071c8213e7" + integrity sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg== + dependencies: + undici-types "~7.16.0" + +"@types/node@>=13.7.0", "@types/node@>=8.1.0": version "24.10.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.1.tgz#91e92182c93db8bd6224fca031e2370cef9a8f01" integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ== @@ -3052,7 +3120,7 @@ dependencies: "@types/node" "*" -"@types/stack-utils@^2.0.0": +"@types/stack-utils@^2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== @@ -3090,7 +3158,7 @@ resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== -"@types/yargs@^17.0.8": +"@types/yargs@^17.0.33": version "17.0.35" resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.35.tgz#07013e46aa4d7d7d50a49e15604c1c5340d4eb24" integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg== @@ -3272,11 +3340,108 @@ resolved "https://registry.yarnpkg.com/@tyriar/fibonacci-heap/-/fibonacci-heap-2.0.9.tgz#df3dcbdb1b9182168601f6318366157ee16666e9" integrity sha512-bYuSNomfn4hu2tPiDN+JZtnzCpSpbJ/PNeulmocDy3xN2X5OkJL65zo6rPZp65cPPhLF9vfT/dgE+RtFRCSxOA== -"@ungap/structured-clone@^1.2.0": +"@ungap/structured-clone@^1.2.0", "@ungap/structured-clone@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== +"@unrs/resolver-binding-android-arm-eabi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81" + integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw== + +"@unrs/resolver-binding-android-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f" + integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g== + +"@unrs/resolver-binding-darwin-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz#b4a8556f42171fb9c9f7bac8235045e82aa0cbdf" + integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== + +"@unrs/resolver-binding-darwin-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc" + integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ== + +"@unrs/resolver-binding-freebsd-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b" + integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw== + +"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a" + integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw== + +"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3" + integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw== + +"@unrs/resolver-binding-linux-arm64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d" + integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ== + +"@unrs/resolver-binding-linux-arm64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0" + integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w== + +"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44" + integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA== + +"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9" + integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ== + +"@unrs/resolver-binding-linux-riscv64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165" + integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew== + +"@unrs/resolver-binding-linux-s390x-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94" + integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg== + +"@unrs/resolver-binding-linux-x64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935" + integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== + +"@unrs/resolver-binding-linux-x64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6" + integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== + +"@unrs/resolver-binding-wasm32-wasi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d" + integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ== + dependencies: + "@napi-rs/wasm-runtime" "^0.2.11" + +"@unrs/resolver-binding-win32-arm64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35" + integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw== + +"@unrs/resolver-binding-win32-ia32-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6" + integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ== + +"@unrs/resolver-binding-win32-x64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" + integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== + "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": version "1.14.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" @@ -3511,7 +3676,7 @@ ansi-colors@4.1.3: resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== -ansi-escapes@^4.2.1: +ansi-escapes@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== @@ -3542,7 +3707,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^5.0.0: +ansi-styles@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== @@ -3557,7 +3722,7 @@ ansis@4.2.0: resolved "https://registry.yarnpkg.com/ansis/-/ansis-4.2.0.tgz#2e6e61c46b11726ac67f78785385618b9e658780" integrity sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig== -anymatch@^3.0.3: +anymatch@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== @@ -3619,7 +3784,7 @@ asn1.js@^5.0.0: dependencies: lodash "^4.17.14" -async@3.2.6, async@^3.2.3, async@^3.2.6: +async@3.2.6, async@^3.2.3: version "3.2.6" resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== @@ -3666,41 +3831,38 @@ axios@^1.12.0, axios@^1.12.2: form-data "^4.0.4" proxy-from-env "^1.1.0" -babel-jest@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" - integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== +babel-jest@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.2.0.tgz#fd44a1ec9552be35ead881f7381faa7d8f3b95ac" + integrity sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw== dependencies: - "@jest/transform" "^29.7.0" - "@types/babel__core" "^7.1.14" - babel-plugin-istanbul "^6.1.1" - babel-preset-jest "^29.6.3" - chalk "^4.0.0" - graceful-fs "^4.2.9" + "@jest/transform" "30.2.0" + "@types/babel__core" "^7.20.5" + babel-plugin-istanbul "^7.0.1" + babel-preset-jest "30.2.0" + chalk "^4.1.2" + graceful-fs "^4.2.11" slash "^3.0.0" -babel-plugin-istanbul@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" - integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== +babel-plugin-istanbul@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz#d8b518c8ea199364cf84ccc82de89740236daf92" + integrity sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA== dependencies: "@babel/helper-plugin-utils" "^7.0.0" "@istanbuljs/load-nyc-config" "^1.0.0" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-instrument "^5.0.4" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-instrument "^6.0.2" test-exclude "^6.0.0" -babel-plugin-jest-hoist@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz#aadbe943464182a8922c3c927c3067ff40d24626" - integrity sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg== +babel-plugin-jest-hoist@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz#94c250d36b43f95900f3a219241e0f4648191ce2" + integrity sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA== dependencies: - "@babel/template" "^7.3.3" - "@babel/types" "^7.3.3" - "@types/babel__core" "^7.1.14" - "@types/babel__traverse" "^7.0.6" + "@types/babel__core" "^7.20.5" -babel-preset-current-node-syntax@^1.0.0: +babel-preset-current-node-syntax@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz#20730d6cdc7dda5d89401cab10ac6a32067acde6" integrity sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg== @@ -3721,13 +3883,13 @@ babel-preset-current-node-syntax@^1.0.0: "@babel/plugin-syntax-private-property-in-object" "^7.14.5" "@babel/plugin-syntax-top-level-await" "^7.14.5" -babel-preset-jest@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz#fa05fa510e7d493896d7b0dd2033601c840f171c" - integrity sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA== +babel-preset-jest@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz#04717843e561347781d6d7f69c81e6bcc3ed11ce" + integrity sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ== dependencies: - babel-plugin-jest-hoist "^29.6.3" - babel-preset-current-node-syntax "^1.0.0" + babel-plugin-jest-hoist "30.2.0" + babel-preset-current-node-syntax "^1.2.0" balanced-match@^1.0.0: version "1.0.2" @@ -3744,10 +3906,10 @@ base64-js@^1.0.2, base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -baseline-browser-mapping@^2.8.25: - version "2.8.32" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz#5de72358cf363ac41e7d642af239f6ac5ed1270a" - integrity sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw== +baseline-browser-mapping@^2.8.25, baseline-browser-mapping@^2.9.0: + version "2.9.17" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz#9d6019766cd7eba738cb5f32c84b9f937cc87780" + integrity sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ== bcryptjs@^2.4.3: version "2.4.3" @@ -3759,11 +3921,6 @@ bignumber.js@^9.0.0: resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7" integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== -bintrees@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8" - integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw== - bip39@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/bip39/-/bip39-3.1.0.tgz#c55a418deaf48826a6ceb34ac55b3ee1577e18a3" @@ -3832,7 +3989,18 @@ braces@^3.0.3: dependencies: fill-range "^7.1.1" -browserslist@^4.24.0, browserslist@^4.26.3: +browserslist@^4.24.0: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== + dependencies: + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" + +browserslist@^4.26.3: version "4.28.0" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.0.tgz#9cefece0a386a17a3cd3d22ebf67b9deca1b5929" integrity sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ== @@ -3923,7 +4091,7 @@ call-bound@^1.0.2: call-bind-apply-helpers "^1.0.2" get-intrinsic "^1.3.0" -callsites@^3.0.0: +callsites@^3.0.0, callsites@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== @@ -3933,15 +4101,15 @@ camelcase@^5.0.0, camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.2.0: +camelcase@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001754: - version "1.0.30001759" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz#d569e7b010372c6b0ca3946e30dada0a2e9d5006" - integrity sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw== +caniuse-lite@^1.0.30001754, caniuse-lite@^1.0.30001759: + version "1.0.30001766" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a" + integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA== "chalk@4.1 - 4.1.2", chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" @@ -3993,16 +4161,21 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b" integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ== -ci-info@^3.2.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" - integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +ci-info@^4.2.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.3.1.tgz#355ad571920810b5623e11d40232f443f16f1daa" + integrity sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA== -cjs-module-lexer@^1.0.0, cjs-module-lexer@^1.2.2: +cjs-module-lexer@^1.2.2: version "1.4.3" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz#0f79731eb8cfe1ec72acd4066efac9d61991b00d" integrity sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q== +cjs-module-lexer@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz#b3ca5101843389259ade7d88c77bd06ce55849ca" + integrity sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ== + class-transformer@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336" @@ -4100,7 +4273,7 @@ co@^4.6.0: resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== -collect-v8-coverage@^1.0.0: +collect-v8-coverage@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz#cc1f01eb8d02298cbc9a437c74c70ab4e5210b80" integrity sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw== @@ -4271,19 +4444,6 @@ cosmiconfig@^8.2.0: parse-json "^5.2.0" path-type "^4.0.0" -create-jest@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" - integrity sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q== - dependencies: - "@jest/types" "^29.6.3" - chalk "^4.0.0" - exit "^0.1.2" - graceful-fs "^4.2.9" - jest-config "^29.7.0" - jest-util "^29.7.0" - prompts "^2.0.1" - create-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" @@ -4353,29 +4513,22 @@ debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, d dependencies: ms "^2.1.3" -debug@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== -dedent@^1.0.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca" - integrity sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ== +dedent@^1.6.0: + version "1.7.1" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.1.tgz#364661eea3d73f3faba7089214420ec2f8f13e15" + integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg== deep-is@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -deepmerge@^4.2.2: +deepmerge@^4.2.2, deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== @@ -4402,7 +4555,7 @@ depd@^2.0.0, depd@~2.0.0: resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -detect-newline@^3.0.0: +detect-newline@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== @@ -4415,11 +4568,6 @@ dezalgo@^1.0.4: asap "^2.0.0" wrappy "1" -diff-sequences@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" - integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== - diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -4512,17 +4660,10 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== -ejs@^3.1.10: - version "3.1.10" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" - integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== - dependencies: - jake "^10.8.5" - -electron-to-chromium@^1.5.249: - version "1.5.263" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.263.tgz#bec8f2887c30001dfacf415c136eae3b4386846a" - integrity sha512-DrqJ11Knd+lo+dv+lltvfMDLU27g14LMdH2b0O3Pio4uk0x+z7OR+JrmyacTPN2M8w3BrZ7/RTwG3R9B7irPlg== +electron-to-chromium@^1.5.249, electron-to-chromium@^1.5.263: + version "1.5.277" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.277.tgz#7164191a07bf32a7e646e68334f402dd60629821" + integrity sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw== emittery@^0.13.1: version "0.13.1" @@ -4779,7 +4920,7 @@ events@3.3.0, events@^3.2.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -execa@^5.0.0: +execa@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== @@ -4809,21 +4950,22 @@ execa@^8.0.1: signal-exit "^4.1.0" strip-final-newline "^3.0.0" -exit@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== +exit-x@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/exit-x/-/exit-x-0.2.2.tgz#1f9052de3b8d99a696b10dad5bced9bdd5c3aa64" + integrity sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ== -expect@^29.0.0, expect@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" - integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== +expect@30.2.0, expect@^30.0.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.2.0.tgz#d4013bed267013c14bc1199cec8aa57cee9b5869" + integrity sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw== dependencies: - "@jest/expect-utils" "^29.7.0" - jest-get-type "^29.6.3" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" + "@jest/expect-utils" "30.2.0" + "@jest/get-type" "30.1.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-util "30.2.0" express@5.1.0: version "5.1.0" @@ -4925,7 +5067,7 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -fb-watchman@^2.0.0: +fb-watchman@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== @@ -4971,13 +5113,6 @@ file-type@^3.3.0: resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" integrity sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA== -filelist@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" - integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== - dependencies: - minimatch "^5.0.1" - fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -5141,7 +5276,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2: +fsevents@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -5268,7 +5403,7 @@ glob@7.2.0: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^10.3.7, glob@^10.4.2: +glob@^10.3.10, glob@^10.3.7, glob@^10.4.2: version "10.5.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== @@ -5316,7 +5451,7 @@ gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.9: +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -5326,6 +5461,18 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +handlebars@^4.7.8: + version "4.7.8" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" + integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.2" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" @@ -5476,7 +5623,7 @@ import-in-the-middle@^1.13.0: cjs-module-lexer "^1.2.2" module-details-from-path "^1.0.3" -import-local@^3.0.2: +import-local@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== @@ -5580,7 +5727,7 @@ is-fullwidth-code-point@^5.0.0: dependencies: get-east-asian-width "^1.3.1" -is-generator-fn@^2.0.0: +is-generator-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== @@ -5642,18 +5789,7 @@ istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== -istanbul-lib-instrument@^5.0.4: - version "5.2.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" - integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== - dependencies: - "@babel/core" "^7.12.3" - "@babel/parser" "^7.14.7" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-coverage "^3.2.0" - semver "^6.3.0" - -istanbul-lib-instrument@^6.0.0: +istanbul-lib-instrument@^6.0.0, istanbul-lib-instrument@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz#fa15401df6c15874bcb2105f773325d78c666765" integrity sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q== @@ -5673,14 +5809,14 @@ istanbul-lib-report@^3.0.0: make-dir "^4.0.0" supports-color "^7.1.0" -istanbul-lib-source-maps@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" - integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== +istanbul-lib-source-maps@^5.0.0: + version "5.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" + integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== dependencies: + "@jridgewell/trace-mapping" "^0.3.23" debug "^4.1.1" istanbul-lib-coverage "^3.0.0" - source-map "^0.6.1" istanbul-reports@^3.1.3: version "3.2.0" @@ -5704,381 +5840,370 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" -jake@^10.8.5: - version "10.9.4" - resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.4.tgz#d626da108c63d5cfb00ab5c25fadc7e0084af8e6" - integrity sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA== - dependencies: - async "^3.2.6" - filelist "^1.0.4" - picocolors "^1.1.1" - -jest-changed-files@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz#1c06d07e77c78e1585d020424dedc10d6e17ac3a" - integrity sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w== +jest-changed-files@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.2.0.tgz#602266e478ed554e1e1469944faa7efd37cee61c" + integrity sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ== dependencies: - execa "^5.0.0" - jest-util "^29.7.0" + execa "^5.1.1" + jest-util "30.2.0" p-limit "^3.1.0" -jest-circus@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.7.0.tgz#b6817a45fcc835d8b16d5962d0c026473ee3668a" - integrity sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw== +jest-circus@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-30.2.0.tgz#98b8198b958748a2f322354311023d1d02e7603f" + integrity sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg== dependencies: - "@jest/environment" "^29.7.0" - "@jest/expect" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" + "@jest/environment" "30.2.0" + "@jest/expect" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - chalk "^4.0.0" + chalk "^4.1.2" co "^4.6.0" - dedent "^1.0.0" - is-generator-fn "^2.0.0" - jest-each "^29.7.0" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-runtime "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" + dedent "^1.6.0" + is-generator-fn "^2.1.0" + jest-each "30.2.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-runtime "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" p-limit "^3.1.0" - pretty-format "^29.7.0" - pure-rand "^6.0.0" + pretty-format "30.2.0" + pure-rand "^7.0.0" slash "^3.0.0" - stack-utils "^2.0.3" + stack-utils "^2.0.6" -jest-cli@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.7.0.tgz#5592c940798e0cae677eec169264f2d839a37995" - integrity sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg== +jest-cli@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-30.2.0.tgz#1780f8e9d66bf84a10b369aea60aeda7697dcc67" + integrity sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA== dependencies: - "@jest/core" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" - chalk "^4.0.0" - create-jest "^29.7.0" - exit "^0.1.2" - import-local "^3.0.2" - jest-config "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - yargs "^17.3.1" - -jest-config@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.7.0.tgz#bcbda8806dbcc01b1e316a46bb74085a84b0245f" - integrity sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ== - dependencies: - "@babel/core" "^7.11.6" - "@jest/test-sequencer" "^29.7.0" - "@jest/types" "^29.6.3" - babel-jest "^29.7.0" - chalk "^4.0.0" - ci-info "^3.2.0" - deepmerge "^4.2.2" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-circus "^29.7.0" - jest-environment-node "^29.7.0" - jest-get-type "^29.6.3" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-runner "^29.7.0" - jest-util "^29.7.0" - jest-validate "^29.7.0" - micromatch "^4.0.4" + "@jest/core" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" + chalk "^4.1.2" + exit-x "^0.2.2" + import-local "^3.2.0" + jest-config "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" + yargs "^17.7.2" + +jest-config@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-30.2.0.tgz#29df8c50e2ad801cc59c406b50176c18c362a90b" + integrity sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA== + dependencies: + "@babel/core" "^7.27.4" + "@jest/get-type" "30.1.0" + "@jest/pattern" "30.0.1" + "@jest/test-sequencer" "30.2.0" + "@jest/types" "30.2.0" + babel-jest "30.2.0" + chalk "^4.1.2" + ci-info "^4.2.0" + deepmerge "^4.3.1" + glob "^10.3.10" + graceful-fs "^4.2.11" + jest-circus "30.2.0" + jest-docblock "30.2.0" + jest-environment-node "30.2.0" + jest-regex-util "30.0.1" + jest-resolve "30.2.0" + jest-runner "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" + micromatch "^4.0.8" parse-json "^5.2.0" - pretty-format "^29.7.0" + pretty-format "30.2.0" slash "^3.0.0" strip-json-comments "^3.1.1" -jest-diff@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" - integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== +jest-diff@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.2.0.tgz#e3ec3a6ea5c5747f605c9e874f83d756cba36825" + integrity sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A== dependencies: - chalk "^4.0.0" - diff-sequences "^29.6.3" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" + "@jest/diff-sequences" "30.0.1" + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + pretty-format "30.2.0" -jest-docblock@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.7.0.tgz#8fddb6adc3cdc955c93e2a87f61cfd350d5d119a" - integrity sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g== +jest-docblock@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-30.2.0.tgz#42cd98d69f887e531c7352309542b1ce4ee10256" + integrity sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA== dependencies: - detect-newline "^3.0.0" + detect-newline "^3.1.0" -jest-each@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.7.0.tgz#162a9b3f2328bdd991beaabffbb74745e56577d1" - integrity sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ== +jest-each@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-30.2.0.tgz#39e623ae71641c2ac3ee69b3ba3d258fce8e768d" + integrity sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ== dependencies: - "@jest/types" "^29.6.3" - chalk "^4.0.0" - jest-get-type "^29.6.3" - jest-util "^29.7.0" - pretty-format "^29.7.0" - -jest-environment-node@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" - integrity sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== - dependencies: - "@jest/environment" "^29.7.0" - "@jest/fake-timers" "^29.7.0" - "@jest/types" "^29.6.3" - "@types/node" "*" - jest-mock "^29.7.0" - jest-util "^29.7.0" + "@jest/get-type" "30.1.0" + "@jest/types" "30.2.0" + chalk "^4.1.2" + jest-util "30.2.0" + pretty-format "30.2.0" -jest-get-type@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" - integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== +jest-environment-node@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-30.2.0.tgz#3def7980ebd2fd86e74efd4d2e681f55ab38da0f" + integrity sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA== + dependencies: + "@jest/environment" "30.2.0" + "@jest/fake-timers" "30.2.0" + "@jest/types" "30.2.0" + "@types/node" "*" + jest-mock "30.2.0" + jest-util "30.2.0" + jest-validate "30.2.0" -jest-haste-map@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz#3c2396524482f5a0506376e6c858c3bbcc17b104" - integrity sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA== +jest-haste-map@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-30.2.0.tgz#808e3889f288603ac70ff0ac047598345a66022e" + integrity sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw== dependencies: - "@jest/types" "^29.6.3" - "@types/graceful-fs" "^4.1.3" + "@jest/types" "30.2.0" "@types/node" "*" - anymatch "^3.0.3" - fb-watchman "^2.0.0" - graceful-fs "^4.2.9" - jest-regex-util "^29.6.3" - jest-util "^29.7.0" - jest-worker "^29.7.0" - micromatch "^4.0.4" + anymatch "^3.1.3" + fb-watchman "^2.0.2" + graceful-fs "^4.2.11" + jest-regex-util "30.0.1" + jest-util "30.2.0" + jest-worker "30.2.0" + micromatch "^4.0.8" walker "^1.0.8" optionalDependencies: - fsevents "^2.3.2" + fsevents "^2.3.3" -jest-leak-detector@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz#5b7ec0dadfdfec0ca383dc9aa016d36b5ea4c728" - integrity sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw== +jest-leak-detector@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz#292fdca7b7c9cf594e1e570ace140b01d8beb736" + integrity sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ== dependencies: - jest-get-type "^29.6.3" - pretty-format "^29.7.0" + "@jest/get-type" "30.1.0" + pretty-format "30.2.0" -jest-matcher-utils@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" - integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== +jest-matcher-utils@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz#69a0d4c271066559ec8b0d8174829adc3f23a783" + integrity sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg== dependencies: - chalk "^4.0.0" - jest-diff "^29.7.0" - jest-get-type "^29.6.3" - pretty-format "^29.7.0" - -jest-message-util@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" - integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== - dependencies: - "@babel/code-frame" "^7.12.13" - "@jest/types" "^29.6.3" - "@types/stack-utils" "^2.0.0" - chalk "^4.0.0" - graceful-fs "^4.2.9" - micromatch "^4.0.4" - pretty-format "^29.7.0" + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + jest-diff "30.2.0" + pretty-format "30.2.0" + +jest-message-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.2.0.tgz#fc97bf90d11f118b31e6131e2b67fc4f39f92152" + integrity sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.2.0" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + micromatch "^4.0.8" + pretty-format "30.2.0" slash "^3.0.0" - stack-utils "^2.0.3" + stack-utils "^2.0.6" -jest-mock@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" - integrity sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== +jest-mock@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.2.0.tgz#69f991614eeb4060189459d3584f710845bff45e" + integrity sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw== dependencies: - "@jest/types" "^29.6.3" + "@jest/types" "30.2.0" "@types/node" "*" - jest-util "^29.7.0" + jest-util "30.2.0" -jest-pnp-resolver@^1.2.2: +jest-pnp-resolver@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== -jest-regex-util@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52" - integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== +jest-regex-util@30.0.1: + version "30.0.1" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" + integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== -jest-resolve-dependencies@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz#1b04f2c095f37fc776ff40803dc92921b1e88428" - integrity sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA== +jest-resolve-dependencies@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz#3370e2c0b49cc560f6a7e8ec3a59dd99525e1a55" + integrity sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w== dependencies: - jest-regex-util "^29.6.3" - jest-snapshot "^29.7.0" + jest-regex-util "30.0.1" + jest-snapshot "30.2.0" -jest-resolve@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.7.0.tgz#64d6a8992dd26f635ab0c01e5eef4399c6bcbc30" - integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== +jest-resolve@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-30.2.0.tgz#2e2009cbd61e8f1f003355d5ec87225412cebcd7" + integrity sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A== dependencies: - chalk "^4.0.0" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-pnp-resolver "^1.2.2" - jest-util "^29.7.0" - jest-validate "^29.7.0" - resolve "^1.20.0" - resolve.exports "^2.0.0" + chalk "^4.1.2" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" + jest-pnp-resolver "^1.2.3" + jest-util "30.2.0" + jest-validate "30.2.0" slash "^3.0.0" - -jest-runner@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.7.0.tgz#809af072d408a53dcfd2e849a4c976d3132f718e" - integrity sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ== - dependencies: - "@jest/console" "^29.7.0" - "@jest/environment" "^29.7.0" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" + unrs-resolver "^1.7.11" + +jest-runner@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-30.2.0.tgz#c62b4c3130afa661789705e13a07bdbcec26a114" + integrity sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ== + dependencies: + "@jest/console" "30.2.0" + "@jest/environment" "30.2.0" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - chalk "^4.0.0" + chalk "^4.1.2" emittery "^0.13.1" - graceful-fs "^4.2.9" - jest-docblock "^29.7.0" - jest-environment-node "^29.7.0" - jest-haste-map "^29.7.0" - jest-leak-detector "^29.7.0" - jest-message-util "^29.7.0" - jest-resolve "^29.7.0" - jest-runtime "^29.7.0" - jest-util "^29.7.0" - jest-watcher "^29.7.0" - jest-worker "^29.7.0" + exit-x "^0.2.2" + graceful-fs "^4.2.11" + jest-docblock "30.2.0" + jest-environment-node "30.2.0" + jest-haste-map "30.2.0" + jest-leak-detector "30.2.0" + jest-message-util "30.2.0" + jest-resolve "30.2.0" + jest-runtime "30.2.0" + jest-util "30.2.0" + jest-watcher "30.2.0" + jest-worker "30.2.0" p-limit "^3.1.0" source-map-support "0.5.13" -jest-runtime@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.7.0.tgz#efecb3141cf7d3767a3a0cc8f7c9990587d3d817" - integrity sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ== - dependencies: - "@jest/environment" "^29.7.0" - "@jest/fake-timers" "^29.7.0" - "@jest/globals" "^29.7.0" - "@jest/source-map" "^29.6.3" - "@jest/test-result" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" +jest-runtime@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-30.2.0.tgz#395ea792cde048db1b0cd1a92dc9cb9f1921bf8a" + integrity sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg== + dependencies: + "@jest/environment" "30.2.0" + "@jest/fake-timers" "30.2.0" + "@jest/globals" "30.2.0" + "@jest/source-map" "30.0.1" + "@jest/test-result" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - chalk "^4.0.0" - cjs-module-lexer "^1.0.0" - collect-v8-coverage "^1.0.0" - glob "^7.1.3" - graceful-fs "^4.2.9" - jest-haste-map "^29.7.0" - jest-message-util "^29.7.0" - jest-mock "^29.7.0" - jest-regex-util "^29.6.3" - jest-resolve "^29.7.0" - jest-snapshot "^29.7.0" - jest-util "^29.7.0" + chalk "^4.1.2" + cjs-module-lexer "^2.1.0" + collect-v8-coverage "^1.0.2" + glob "^10.3.10" + graceful-fs "^4.2.11" + jest-haste-map "30.2.0" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-regex-util "30.0.1" + jest-resolve "30.2.0" + jest-snapshot "30.2.0" + jest-util "30.2.0" slash "^3.0.0" strip-bom "^4.0.0" -jest-snapshot@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz#c2c574c3f51865da1bb329036778a69bf88a6be5" - integrity sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw== - dependencies: - "@babel/core" "^7.11.6" - "@babel/generator" "^7.7.2" - "@babel/plugin-syntax-jsx" "^7.7.2" - "@babel/plugin-syntax-typescript" "^7.7.2" - "@babel/types" "^7.3.3" - "@jest/expect-utils" "^29.7.0" - "@jest/transform" "^29.7.0" - "@jest/types" "^29.6.3" - babel-preset-current-node-syntax "^1.0.0" - chalk "^4.0.0" - expect "^29.7.0" - graceful-fs "^4.2.9" - jest-diff "^29.7.0" - jest-get-type "^29.6.3" - jest-matcher-utils "^29.7.0" - jest-message-util "^29.7.0" - jest-util "^29.7.0" - natural-compare "^1.4.0" - pretty-format "^29.7.0" - semver "^7.5.3" - -jest-util@^29.0.0, jest-util@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" - integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== - dependencies: - "@jest/types" "^29.6.3" +jest-snapshot@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-30.2.0.tgz#266fbbb4b95fc4665ce6f32f1f38eeb39f4e26d0" + integrity sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA== + dependencies: + "@babel/core" "^7.27.4" + "@babel/generator" "^7.27.5" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.27.1" + "@babel/types" "^7.27.3" + "@jest/expect-utils" "30.2.0" + "@jest/get-type" "30.1.0" + "@jest/snapshot-utils" "30.2.0" + "@jest/transform" "30.2.0" + "@jest/types" "30.2.0" + babel-preset-current-node-syntax "^1.2.0" + chalk "^4.1.2" + expect "30.2.0" + graceful-fs "^4.2.11" + jest-diff "30.2.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-util "30.2.0" + pretty-format "30.2.0" + semver "^7.7.2" + synckit "^0.11.8" + +jest-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.2.0.tgz#5142adbcad6f4e53c2776c067a4db3c14f913705" + integrity sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA== + dependencies: + "@jest/types" "30.2.0" "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.2" -jest-validate@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.7.0.tgz#7bf705511c64da591d46b15fce41400d52147d9c" - integrity sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw== +jest-validate@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-30.2.0.tgz#273eaaed4c0963b934b5b31e96289edda6e0a2ef" + integrity sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw== dependencies: - "@jest/types" "^29.6.3" - camelcase "^6.2.0" - chalk "^4.0.0" - jest-get-type "^29.6.3" + "@jest/get-type" "30.1.0" + "@jest/types" "30.2.0" + camelcase "^6.3.0" + chalk "^4.1.2" leven "^3.1.0" - pretty-format "^29.7.0" + pretty-format "30.2.0" -jest-watcher@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.7.0.tgz#7810d30d619c3a62093223ce6bb359ca1b28a2f2" - integrity sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g== +jest-watcher@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-30.2.0.tgz#f9c055de48e18c979e7756a3917e596e2d69b07b" + integrity sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg== dependencies: - "@jest/test-result" "^29.7.0" - "@jest/types" "^29.6.3" + "@jest/test-result" "30.2.0" + "@jest/types" "30.2.0" "@types/node" "*" - ansi-escapes "^4.2.1" - chalk "^4.0.0" + ansi-escapes "^4.3.2" + chalk "^4.1.2" emittery "^0.13.1" - jest-util "^29.7.0" - string-length "^4.0.1" + jest-util "30.2.0" + string-length "^4.0.2" -jest-worker@^27.4.5: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" - integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== +jest-worker@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.2.0.tgz#fd5c2a36ff6058ec8f74366ec89538cc99539d26" + integrity sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g== dependencies: "@types/node" "*" + "@ungap/structured-clone" "^1.3.0" + jest-util "30.2.0" merge-stream "^2.0.0" - supports-color "^8.0.0" + supports-color "^8.1.1" -jest-worker@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.7.0.tgz#acad073acbbaeb7262bd5389e1bcf43e10058d4a" - integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== +jest-worker@^27.4.5: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" + integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== dependencies: "@types/node" "*" - jest-util "^29.7.0" merge-stream "^2.0.0" supports-color "^8.0.0" -jest@29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/jest/-/jest-29.7.0.tgz#994676fc24177f088f1c5e3737f5697204ff2613" - integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== +jest@^30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-30.2.0.tgz#9f0a71e734af968f26952b5ae4b724af82681630" + integrity sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A== dependencies: - "@jest/core" "^29.7.0" - "@jest/types" "^29.6.3" - import-local "^3.0.2" - jest-cli "^29.7.0" + "@jest/core" "30.2.0" + "@jest/types" "30.2.0" + import-local "^3.2.0" + jest-cli "30.2.0" joycon@^3.1.1: version "3.1.1" @@ -6284,11 +6409,6 @@ keyv@^5.3.3: dependencies: "@keyv/serialize" "^1.1.1" -kleur@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" - integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== - kuler@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" @@ -6389,11 +6509,6 @@ lodash.defaults@^4.2.0: resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== -lodash.get@^4.4.2: - version "4.4.2" - resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" - integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== - lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -6591,7 +6706,7 @@ methods@^1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== -micromatch@^4.0.0, micromatch@^4.0.4, micromatch@^4.0.8: +micromatch@^4.0.0, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -6676,13 +6791,6 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^5.0.1: - version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== - dependencies: - brace-expansion "^2.0.1" - minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" @@ -6772,6 +6880,11 @@ nan@^2.22.2: resolved "https://registry.yarnpkg.com/nan/-/nan-2.23.1.tgz#6f86a31dd87e3d1eb77512bf4b9e14c8aded3975" integrity sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw== +napi-postinstall@^0.3.0: + version "0.3.4" + resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz#7af256d6588b5f8e952b9190965d6b019653bbb9" + integrity sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ== + natural-compare-lite@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" @@ -7232,11 +7345,16 @@ picomatch@4.0.2: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== -picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: +picomatch@^2.0.4, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + pidtree@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" @@ -7307,7 +7425,7 @@ pino@^10.0.0: sonic-boom "^4.0.1" thread-stream "^3.0.0" -pirates@^4.0.4: +pirates@^4.0.7: version "4.0.7" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== @@ -7319,11 +7437,6 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkginfo@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff" - integrity sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ== - pluralize@8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" @@ -7390,14 +7503,14 @@ pretty-bytes@^5.6.0: resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== -pretty-format@^29.0.0, pretty-format@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" - integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== +pretty-format@30.2.0, pretty-format@^30.0.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.2.0.tgz#2d44fe6134529aed18506f6d11509d8a62775ebe" + integrity sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA== dependencies: - "@jest/schemas" "^29.6.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" + "@jest/schemas" "30.0.5" + ansi-styles "^5.2.0" + react-is "^18.3.1" prettysize@^2.0.0: version "2.0.0" @@ -7409,31 +7522,6 @@ process-warning@^5.0.0: resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-5.0.0.tgz#566e0bf79d1dff30a72d8bbbe9e8ecefe8d378d7" integrity sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA== -prom-client@^15.0.0: - version "15.1.3" - resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-15.1.3.tgz#69fa8de93a88bc9783173db5f758dc1c69fa8fc2" - integrity sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g== - dependencies: - "@opentelemetry/api" "^1.4.0" - tdigest "^0.1.1" - -prometheus-api-metrics@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/prometheus-api-metrics/-/prometheus-api-metrics-4.0.0.tgz#f69b2ab5dffea5638d680b9287613d08cbf855e6" - integrity sha512-xZq/fFTCOfEFCWRCok5cF969Xs2qPOlRkO8Tn3rVijeGkVdMEwlsDUM3oDXs/VdzMgCg5NFGEWHlGq4E3JgKKw== - dependencies: - debug "^3.2.7" - lodash.get "^4.4.2" - pkginfo "^0.4.1" - -prompts@^2.0.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" - integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== - dependencies: - kleur "^3.0.3" - sisteransi "^1.0.5" - proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -7483,10 +7571,10 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -pure-rand@^6.0.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" - integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== +pure-rand@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-7.0.1.tgz#6f53a5a9e3e4a47445822af96821ca509ed37566" + integrity sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ== qrcode@^1.4.4: version "1.5.4" @@ -7549,7 +7637,7 @@ raw-body@^3.0.1: iconv-lite "~0.7.0" unpipe "~1.0.0" -react-is@^18.0.0: +react-is@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== @@ -7647,12 +7735,7 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve.exports@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.3.tgz#41955e6f1b4013b7586f873749a635dea07ebe3f" - integrity sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A== - -resolve@^1.20.0, resolve@^1.22.1, resolve@^1.22.8: +resolve@^1.22.1, resolve@^1.22.8: version "1.22.11" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== @@ -7794,12 +7877,12 @@ secure-json-parse@^4.0.0: resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz#4f1ab41c67a13497ea1b9131bb4183a22865477c" integrity sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA== -semver@^6.3.0, semver@^6.3.1: +semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.7.1, semver@^7.7.2: +semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.7.2, semver@^7.7.3: version "7.7.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== @@ -7947,7 +8030,7 @@ side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: +signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -7969,11 +8052,6 @@ sinon@^18.0.1: nise "^6.0.0" supports-color "^7" -sisteransi@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" - integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== - slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -8069,7 +8147,7 @@ stack-trace@0.0.x: resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== -stack-utils@^2.0.3: +stack-utils@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== @@ -8113,7 +8191,7 @@ string-argv@^0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -string-length@^4.0.1: +string-length@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== @@ -8270,7 +8348,7 @@ supports-color@^7, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.0.0: +supports-color@^8.0.0, supports-color@^8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== @@ -8315,6 +8393,13 @@ synckit@^0.11.7: dependencies: "@pkgr/core" "^0.2.9" +synckit@^0.11.8: + version "0.11.12" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.12.tgz#abe74124264fbc00a48011b0d98bdc1cffb64a7b" + integrity sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ== + dependencies: + "@pkgr/core" "^0.2.9" + tapable@^2.2.0, tapable@^2.2.1, tapable@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" @@ -8341,13 +8426,6 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" -tdigest@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced" - integrity sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA== - dependencies: - bintrees "1.0.2" - terser-webpack-plugin@^5.3.11: version "5.3.14" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz#9031d48e57ab27567f02ace85c7d690db66c3e06" @@ -8436,20 +8514,19 @@ ts-api-utils@^1.0.1: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064" integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw== -ts-jest@29.3.2: - version "29.3.2" - resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.3.2.tgz#0576cdf0a507f811fe73dcd16d135ce89f8156cb" - integrity sha512-bJJkrWc6PjFVz5g2DGCNUo8z7oFEYaz1xP1NpeDU7KNLMWPpEyV8Chbpkn8xjzgRDpQhnGMyvyldoL7h8JXyug== +ts-jest@^29.4.6: + version "29.4.6" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.6.tgz#51cb7c133f227396818b71297ad7409bb77106e9" + integrity sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA== dependencies: bs-logger "^0.2.6" - ejs "^3.1.10" fast-json-stable-stringify "^2.1.0" - jest-util "^29.0.0" + handlebars "^4.7.8" json5 "^2.2.3" lodash.memoize "^4.1.2" make-error "^1.3.6" - semver "^7.7.1" - type-fest "^4.39.1" + semver "^7.7.3" + type-fest "^4.41.0" yargs-parser "^21.1.1" ts-loader@^9.5.2: @@ -8501,7 +8578,7 @@ tsconfig-paths@4.2.0, tsconfig-paths@^4.1.2: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@2.8.1, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.6.2: +tslib@2.8.1, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.6.2: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -8545,7 +8622,7 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== -type-fest@^4.39.1: +type-fest@^4.41.0: version "4.41.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== @@ -8577,6 +8654,11 @@ typescript@5.9.3, typescript@^5.8.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== +uglify-js@^3.1.4: + version "3.19.3" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" + integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== + uid@2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/uid/-/uid-2.0.2.tgz#4b5782abf0f2feeefc00fa88006b2b3b7af3e3b9" @@ -8642,10 +8724,37 @@ unpipe@~1.0.0: resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== -update-browserslist-db@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz#7802aa2ae91477f255b86e0e46dbc787a206ad4a" - integrity sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A== +unrs-resolver@^1.7.11: + version "1.11.1" + resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz#be9cd8686c99ef53ecb96df2a473c64d304048a9" + integrity sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg== + dependencies: + napi-postinstall "^0.3.0" + optionalDependencies: + "@unrs/resolver-binding-android-arm-eabi" "1.11.1" + "@unrs/resolver-binding-android-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-x64" "1.11.1" + "@unrs/resolver-binding-freebsd-x64" "1.11.1" + "@unrs/resolver-binding-linux-arm-gnueabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm-musleabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-arm64-musl" "1.11.1" + "@unrs/resolver-binding-linux-ppc64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-musl" "1.11.1" + "@unrs/resolver-binding-linux-s390x-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-musl" "1.11.1" + "@unrs/resolver-binding-wasm32-wasi" "1.11.1" + "@unrs/resolver-binding-win32-arm64-msvc" "1.11.1" + "@unrs/resolver-binding-win32-ia32-msvc" "1.11.1" + "@unrs/resolver-binding-win32-x64-msvc" "1.11.1" + +update-browserslist-db@^1.1.4, update-browserslist-db@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== dependencies: escalade "^3.2.0" picocolors "^1.1.1" @@ -8819,6 +8928,11 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" @@ -8869,13 +8983,13 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -write-file-atomic@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" - integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== +write-file-atomic@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7" + integrity sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw== dependencies: imurmurhash "^0.1.4" - signal-exit "^3.0.7" + signal-exit "^4.0.1" ws@^8.17.1: version "8.18.3" @@ -8960,7 +9074,7 @@ yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yargs@^17.3.1, yargs@^17.7.2: +yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==