From 0f470e6f6598422a6e5e537b237b0f43bf00c45b Mon Sep 17 00:00:00 2001 From: Xing Fan Date: Thu, 11 Sep 2025 12:14:28 +0000 Subject: [PATCH 01/10] draft implementation for Atlas service discovery --- l10n/bundle.l10n.json | 14 + package-lock.json | 18 + package.json | 1 + .../utils/AtlasAdministrationClient.ts | 538 ++++++++++++++++++ .../utils/AtlasAuthManager.ts | 144 +++++ .../utils/AtlasCredentialCache.ts | 147 +++++ .../utils/AtlasHttpClient.ts | 149 +++++ 7 files changed, 1011 insertions(+) create mode 100644 src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts create mode 100644 src/plugins/service-mongo-atlas/utils/AtlasAuthManager.ts create mode 100644 src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts create mode 100644 src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index a55a2f9d0..db0ca45cc 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -132,6 +132,7 @@ "Delete database \"{databaseId}\" and its contents?": "Delete database \"{databaseId}\" and its contents?", "Delete selected document(s)": "Delete selected document(s)", "Deleting...": "Deleting...", + "Digest credentials not found": "Digest credentials not found", "Disable TLS/SSL (Not recommended)": "Disable TLS/SSL (Not recommended)", "Disable TLS/SSL checks when connecting.": "Disable TLS/SSL checks when connecting.", "Do not rely on case to distinguish between databases. For example, you cannot use two databases with names like, salesData and SalesData.": "Do not rely on case to distinguish between databases. For example, you cannot use two databases with names like, salesData and SalesData.", @@ -195,9 +196,12 @@ "Failed to access Azure Databases VS Code Extension storage for migration: {error}": "Failed to access Azure Databases VS Code Extension storage for migration: {error}", "Failed to connect to \"{cluster}\"": "Failed to connect to \"{cluster}\"", "Failed to connect to VM \"{vmName}\"": "Failed to connect to VM \"{vmName}\"", + "Failed to create access list entries for project {0}: {1} {2}": "Failed to create access list entries for project {0}: {1} {2}", "Failed to create Azure management clients: {0}": "Failed to create Azure management clients: {0}", "Failed to create role assignment \"{0}\" for the {2} resource \"{1}\".": "Failed to create role assignment \"{0}\" for the {2} resource \"{1}\".", "Failed to create role assignment(s).": "Failed to create role assignment(s).", + "Failed to delete access list entry {0} from project {1}: {2}": "Failed to delete access list entry {0} from project {1}: {2}", + "Failed to delete access list entry {0} from project {1}: {2} {3}": "Failed to delete access list entry {0} from project {1}: {2} {3}", "Failed to delete documents. Unknown error.": "Failed to delete documents. Unknown error.", "Failed to delete item \"{0}\".": "Failed to delete item \"{0}\".", "Failed to delete secrets for item \"{0}\".": "Failed to delete secrets for item \"{0}\".", @@ -205,9 +209,16 @@ "Failed to extract cluster credentials from the selected node.": "Failed to extract cluster credentials from the selected node.", "Failed to extract the connection string from the selected account.": "Failed to extract the connection string from the selected account.", "Failed to find commandId on generic tree item.": "Failed to find commandId on generic tree item.", + "Failed to get access list for project {0}: {1} {2}": "Failed to get access list for project {0}: {1} {2}", + "Failed to get cluster {0} in project {1}: {2} {3}": "Failed to get cluster {0} in project {1}: {2} {3}", "Failed to get public IP": "Failed to get public IP", "Failed to initialize Azure management clients": "Failed to initialize Azure management clients", + "Failed to list Atlas projects: {0} {1}": "Failed to list Atlas projects: {0} {1}", + "Failed to list clusters for project {0}: {1} {2}": "Failed to list clusters for project {0}: {1} {2}", + "Failed to list database users for project {0}: {1} {2}": "Failed to list database users for project {0}: {1} {2}", "Failed to obtain Entra ID token.": "Failed to obtain Entra ID token.", + "Failed to obtain OAuth token: {0} {1}": "Failed to obtain OAuth token: {0} {1}", + "Failed to obtain valid OAuth token": "Failed to obtain valid OAuth token", "Failed to parse secrets for key {0}:": "Failed to parse secrets for key {0}:", "Failed to process URI: {0}": "Failed to process URI: {0}", "Failed to rename the connection.": "Failed to rename the connection.", @@ -291,6 +302,7 @@ "New Local Connection": "New Local Connection", "New Local Connection…": "New Local Connection…", "No": "No", + "No Atlas credentials found for organization {0}": "No Atlas credentials found for organization {0}", "No authentication method selected.": "No authentication method selected.", "No authentication methods available for \"{cluster}\".": "No authentication methods available for \"{cluster}\".", "No Azure subscription found for this tree item.": "No Azure subscription found for this tree item.", @@ -311,6 +323,7 @@ "Not connected to any MongoDB database.": "Not connected to any MongoDB database.", "Note: This confirmation type can be configured in the extension settings.": "Note: This confirmation type can be configured in the extension settings.", "Note: You can disable these URL handling confirmations in the extension settings.": "Note: You can disable these URL handling confirmations in the extension settings.", + "OAuth credentials not found for organization {0}": "OAuth credentials not found for organization {0}", "Open Collection": "Open Collection", "Open installation page": "Open installation page", "Opening DocumentDB connection…": "Opening DocumentDB connection…", @@ -448,6 +461,7 @@ "Unrecognized node type encountered. Could not parse {constructorCall} as part of {functionCall}": "Unrecognized node type encountered. Could not parse {constructorCall} as part of {functionCall}", "Unrecognized node type encountered. We could not parse {text}": "Unrecognized node type encountered. We could not parse {text}", "Unrecognized token. Token text: {text}": "Unrecognized token. Token text: {text}", + "Unsupported Atlas authentication type: {0}": "Unsupported Atlas authentication type: {0}", "Unsupported authentication mechanism. Only \"Username and Password\" (SCRAM-SHA-256) is supported.": "Unsupported authentication mechanism. Only \"Username and Password\" (SCRAM-SHA-256) is supported.", "Unsupported authentication mechanism. Only SCRAM-SHA-256 (username/password) is supported.": "Unsupported authentication mechanism. Only SCRAM-SHA-256 (username/password) is supported.", "Unsupported authentication method: {0}": "Unsupported authentication method: {0}", diff --git a/package-lock.json b/package-lock.json index d4363c39d..9a8e3f026 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "antlr4ts": "^0.5.0-alpha.4", "bson": "~6.10.4", "denque": "~2.1.0", + "digest-fetch": "^3.1.1", "es-toolkit": "~1.39.7", "monaco-editor": "~0.51.0", "mongodb": "~6.17.0", @@ -8049,6 +8050,11 @@ "license": "Apache-2.0", "optional": true }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -9593,6 +9599,18 @@ "node": ">=0.3.1" } }, + "node_modules/digest-fetch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-3.1.1.tgz", + "integrity": "sha512-sfMKKDctvDUTljFnqTBeDl/20vZtKKNZ3XQleht2j3Ky1xuQLuvE9lT+Fvp3EZxJHwAk6RXeEEL4DCmKDSd8xA==", + "license": "ISC", + "dependencies": { + "base-64": "^0.1.0", + "js-sha256": "^0.9.0", + "js-sha512": "^0.8.0", + "md5": "^2.3.0" + } + }, "node_modules/discontinuous-range": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", diff --git a/package.json b/package.json index cc15e2f85..640bade8f 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "antlr4ts": "^0.5.0-alpha.4", "bson": "~6.10.4", "denque": "~2.1.0", + "digest-fetch": "^3.1.1", "es-toolkit": "~1.39.7", "monaco-editor": "~0.51.0", "mongodb": "~6.17.0", diff --git a/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts new file mode 100644 index 000000000..f66416e5c --- /dev/null +++ b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts @@ -0,0 +1,538 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { AtlasHttpClient } from './AtlasHttpClient'; + +/** + * Atlas project (group) information + */ +export interface AtlasProject { + id?: string; + name: string; + orgId: string; + created: string; + clusterCount: number; + links?: Array<{ + href: string; + rel: string; + }>; + regionUsageRestrictions?: 'COMMERCIAL_FEDRAMP_REGIONS_ONLY' | 'GOV_REGIONS_ONLY'; + tags?: Array<{ + key: string; + value: string; + }>; + withDefaultAlertsSettings?: boolean; +} + +/** + * Atlas cluster information + */ +export interface AtlasCluster { + id?: string; + name?: string; + groupId?: string; + mongoDBMajorVersion?: string; + mongoDBVersion?: string; + clusterType: 'REPLICASET' | 'SHARDED' | 'GEOSHARDED'; + providerSettings: { + providerName: string; + regionName: string; + instanceSizeName: string; + }; + connectionStrings?: { + awsPrivateLink?: object; + awsPrivateLinkSrv?: object; + standard?: string; + standardSrv?: string; + private?: string; + privateEndpoint?: Array<{ + connectionString?: string; + endpoints?: Array<{ + endpointId?: string; + providerName?: 'AWS' | 'AZURE' | 'GCP'; + region?: string; + }>; + srvConnectionString?: string; + srvShardOptimizedConnectionString?: string; + type?: 'MONGOD' | 'MONGOS'; + }>; + privateSrv?: string; + }; + stateName: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'DELETED' | 'REPAIRING'; + createDate?: string; // DATE-TIME + links?: Array<{ + href: string; + rel: string; + }>; + acceptDataRisksAndForceReplicaSetReconfig?: string; // DATE-TIME + advancedConfiguration?: { + customOpensslCipherConfigTls12?: Array; + minimumEnabledTlsProtocol?: 'TLS1_0' | 'TLS1_1' | 'TLS1_2'; + tlsCipherConfigMode?: 'CUSTOM' | 'DEFAULT'; + }; + backupEnabled?: boolean; + biConnector?: { + enabled?: boolean; + readPreference?: 'PRIMARY' | 'SECONDARY' | 'ANALYTICS'; + }; + configServerManagementMode?: 'ATLAS_MANAGED' | 'FIXED_TO_DEDICATED'; + configServerType?: 'DEDICATED' | 'EMBEDDED'; + diskWarmingMode?: 'FULLY_WARMED' | 'VISIBLE_EARLIER'; + encryptionAtRestProvider?: 'AWS' | 'AZURE' | 'GCP' | 'NONE'; + featureCompatibilityVersion?: string; + featureCompatibilityVersionExpirationDate?: string; // DATE-TIME + globalClusterSelfManagedSharding?: boolean; + mongoDBEmployeeAccessGrant?: { + expirationTime: string; // DATE-TIME + grantType: + | 'CLUSTER_DATABASE_LOGS' + | 'CLUSTER_INFRASTRUCTURE' + | 'CLUSTER_INFRASTRUCTURE_AND_APP_SERVICES_SYNC_DATA'; + links?: Array<{ + href: string; + rel: string; + }>; + }; + paused?: boolean; + pitEnabled?: boolean; + redactClientLogData?: boolean; + replicaSetScalingStrategy?: 'SEQUENTIAL' | 'WORKLOAD_TYPE' | 'NODE_TYPE'; + replicationSpecs?: Array<{ + id?: string; + regionConfigs?: Array<{ + electableSpecs?: { + diskSizeGB?: number; // DOUBLE + diskIOPS?: number; // INTEGER + ebsVolumeType?: 'STANDARD' | 'PROVISIONED'; + instanceSize?: + | 'M10' + | 'M20' + | 'M30' + | 'M40' + | 'M50' + | 'M60' + | 'M80' + | 'M100' + | 'M140' + | 'M200' + | 'M300' + | 'R40' + | 'R50' + | 'R60' + | 'R80' + | 'R200' + | 'R300' + | 'R400' + | 'R700' + | 'M40_NVME' + | 'M50_NVME' + | 'M60_NVME' + | 'M80_NVME' + | 'M200_NVME' + | 'M400_NVME'; + nodeCount?: number; // INTEGER + }; + priority?: number; // INTEGER, Minimum 0, Maximum 7 + providerName?: 'AWS' | 'AZURE' | 'GCP' | 'TENANT'; // DISCRIMINATOR + regionName?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-tenant-object-regionname + analyticsAutoScaling?: { + compute?: { + enabled: boolean; + maxInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-maxinstancesize + minInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-mininstancesize + predictiveEnabled?: boolean; + scaleDownEnabled?: boolean; + }; + diskGB?: { + enabled?: boolean; + }; + }; + analyticsSpecs?: object; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsspecs + autoScaling?: { + compute?: { + enabled: boolean; + maxInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-maxinstancesize + minInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-mininstancesize + predictiveEnabled?: boolean; + scaleDownEnabled?: boolean; + }; + diskGB?: { + enabled?: boolean; + }; + }; + readOnlySpecs?: object; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-readonlyspecs + zoneId?: string; + zoneName?: string; + }>; + rootCertType?: 'ISRGROOTX1'; + stateName?: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'REPAIRING'; + tags?: Array<{ + key: string; + value: string; + }>; + terminationProtectionEnabled?: boolean; + versionReleaseSystem?: 'LTS' | 'CONTINUOUS'; + }>; +} + +/** + * Atlas database user information + */ +export interface AtlasDatabaseUser { + username: string; // Max length is 1024 + databaseName: 'admin' | '$external'; + groupId: string; + roles: Array<{ + roleName: string; + databaseName: string; + collectionName?: string; + }>; + scopes?: Array<{ + name: string; + type: 'CLUSTER' | 'DATA_LAKE' | 'STREAM'; + }>; + labels?: Array<{ + key: string; + value: string; + }>; + ldapAuthType?: 'NONE' | 'USER' | 'GROUP'; + x509Type?: 'NONE' | 'MANAGED' | 'CUSTOMER'; + awsIAMType?: 'NONE' | 'USER' | 'ROLE'; + links?: Array<{ + href: string; + rel: string; + }>; + deleteAfterDate?: string; // DATE-TIME + description?: string; // Max 100 chars + oidcAuthType?: 'NONE' | 'USER' | 'IDP_GROUP'; +} + +/** + * Atlas IP access list entry + */ +export interface AtlasAccessListEntry { + groupId: string; + ipAddress?: string; + cidrBlock?: string; + awsSecurityGroup?: string; + comment?: string; + deleteAfterDate?: string; + links?: Array<{ + href: string; + rel: string; + }>; +} + +/** + * Response wrapper for paginated Atlas API responses + */ +export interface AtlasApiResponse { + results: T[]; + totalCount: number; + links?: Array<{ + href: string; + rel: string; + }>; +} + +/** + * Parameters for creating IP access list entries + */ +export interface CreateAccessListEntryParams { + ipAddress?: string; + cidrBlock?: string; + awsSecurityGroup?: string; + comment?: string; + deleteAfterDate?: string; +} + +/** + * MongoDB Atlas Administration Client for managing Atlas resources + * Provides methods for projects, clusters, database users, and IP access lists + */ +export class AtlasAdministrationClient { + /** + * Lists all projects (groups) accessible to the authenticated user + * + * @param orgId - The organization id for the Atlas credential instance + * @param options - Optional query parameters + * @returns Promise resolving to list of Atlas projects + */ + public static async listProjects( + orgId: string, + options: { + pageNum?: number; + itemsPerPage?: number; + includeCount?: boolean; + } = {}, + ): Promise> { + const queryParams = new URLSearchParams(); + + if (options.pageNum !== undefined) { + queryParams.append('pageNum', options.pageNum.toString()); + } + if (options.itemsPerPage !== undefined) { + queryParams.append('itemsPerPage', options.itemsPerPage.toString()); + } + if (options.includeCount !== undefined) { + queryParams.append('includeCount', options.includeCount.toString()); + } + + const endpoint = `/groups${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + const response = await AtlasHttpClient.get(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(l10n.t('Failed to list Atlas projects: {0} {1}', response.status.toString(), errorText)); + } + + return response.json() as Promise>; + } + + /** + * Lists all clusters within a specific project + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param options - Optional query parameters + * @returns Promise resolving to list of Atlas clusters + */ + public static async listClusters( + orgId: string, + projectId: string, + options: { + pageNum?: number; + itemsPerPage?: number; + includeCount?: boolean; + } = {}, + ): Promise> { + const queryParams = new URLSearchParams(); + + if (options.pageNum !== undefined) { + queryParams.append('pageNum', options.pageNum.toString()); + } + if (options.itemsPerPage !== undefined) { + queryParams.append('itemsPerPage', options.itemsPerPage.toString()); + } + if (options.includeCount !== undefined) { + queryParams.append('includeCount', options.includeCount.toString()); + } + + const endpoint = `/groups/${projectId}/clusters${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + const response = await AtlasHttpClient.get(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to list clusters for project {0}: {1} {2}', + projectId, + response.status.toString(), + errorText, + ), + ); + } + + return response.json() as Promise>; + } + + /** + * Gets detailed information about a specific cluster, including connection strings + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param clusterName - The name of the cluster + * @returns Promise resolving to cluster details with connection strings + */ + public static async getCluster(orgId: string, projectId: string, clusterName: string): Promise { + const endpoint = `/groups/${projectId}/clusters/${clusterName}`; + const response = await AtlasHttpClient.get(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to get cluster {0} in project {1}: {2} {3}', + clusterName, + projectId, + response.status.toString(), + errorText, + ), + ); + } + + return response.json() as Promise; + } + + /** + * Lists all database users for a specific project + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param options - Optional query parameters + * @returns Promise resolving to list of database users + */ + public static async listDatabaseUsers( + orgId: string, + projectId: string, + options: { + pageNum?: number; + itemsPerPage?: number; + includeCount?: boolean; + } = {}, + ): Promise> { + const queryParams = new URLSearchParams(); + + if (options.pageNum !== undefined) { + queryParams.append('pageNum', options.pageNum.toString()); + } + if (options.itemsPerPage !== undefined) { + queryParams.append('itemsPerPage', options.itemsPerPage.toString()); + } + if (options.includeCount !== undefined) { + queryParams.append('includeCount', options.includeCount.toString()); + } + + const endpoint = `/groups/${projectId}/databaseUsers${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + const response = await AtlasHttpClient.get(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to list database users for project {0}: {1} {2}', + projectId, + response.status.toString(), + errorText, + ), + ); + } + + return response.json() as Promise>; + } + + /** + * Gets the IP access list (firewall entries) for a specific project + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param options - Optional query parameters + * @returns Promise resolving to list of access list entries + */ + public static async getAccessList( + orgId: string, + projectId: string, + options: { + pageNum?: number; + itemsPerPage?: number; + includeCount?: boolean; + } = {}, + ): Promise> { + const queryParams = new URLSearchParams(); + + if (options.pageNum !== undefined) { + queryParams.append('pageNum', options.pageNum.toString()); + } + if (options.itemsPerPage !== undefined) { + queryParams.append('itemsPerPage', options.itemsPerPage.toString()); + } + if (options.includeCount !== undefined) { + queryParams.append('includeCount', options.includeCount.toString()); + } + + const endpoint = `/groups/${projectId}/accessList${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + const response = await AtlasHttpClient.get(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to get access list for project {0}: {1} {2}', + projectId, + response.status.toString(), + errorText, + ), + ); + } + + return response.json() as Promise>; + } + + /** + * Creates one or more IP access list entries for a project + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param entries - Array of access list entries to create + * @returns Promise resolving to created access list entries + */ + public static async createAccessListEntries( + orgId: string, + projectId: string, + entries: CreateAccessListEntryParams[], + ): Promise> { + const endpoint = `/groups/${projectId}/accessList`; + const response = await AtlasHttpClient.post(orgId, endpoint, entries); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to create access list entries for project {0}: {1} {2}', + projectId, + response.status.toString(), + errorText, + ), + ); + } + + return response.json() as Promise>; + } + + /** + * Deletes a specific IP access list entry from a project + * + * @param orgId - The organization id for the Atlas credential instance + * @param projectId - The Atlas project ID + * @param entryId - The ID of the access list entry to delete (IP address or CIDR block) + * @returns Promise resolving when deletion is complete + */ + public static async deleteAccessListEntry(orgId: string, projectId: string, entryId: string): Promise { + // URL encode the entry ID to handle IP addresses and CIDR blocks properly + const encodedEntryId = encodeURIComponent(entryId); + const endpoint = `/groups/${projectId}/accessList/${encodedEntryId}`; + + try { + const response = await AtlasHttpClient.delete(orgId, endpoint); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + l10n.t( + 'Failed to delete access list entry {0} from project {1}: {2} {3}', + entryId, + projectId, + response.status.toString(), + errorText, + ), + ); + } + } catch (error) { + // Re-throw known errors or wrap unknown ones + if (error instanceof Error) { + throw error; + } + throw new Error( + l10n.t( + 'Failed to delete access list entry {0} from project {1}: {2}', + entryId, + projectId, + String(error), + ), + ); + } + } +} diff --git a/src/plugins/service-mongo-atlas/utils/AtlasAuthManager.ts b/src/plugins/service-mongo-atlas/utils/AtlasAuthManager.ts new file mode 100644 index 000000000..c82cf6dc3 --- /dev/null +++ b/src/plugins/service-mongo-atlas/utils/AtlasAuthManager.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { type AtlasCredentials, AtlasCredentialCache } from './AtlasCredentialCache'; + +/** + * Response from Atlas OAuth token request + */ +export interface AtlasOAuthTokenResponse { + access_token: string; + expires_in: number; + token_type: string; +} + +/** + * Atlas authentication manager that handles both OAuth 2.0 and HTTP Digest authentication + * for MongoDB Atlas Service Discovery API requests. + */ +export class AtlasAuthManager { + private static readonly ATLAS_OAUTH_TOKEN_URL = 'https://cloud.mongodb.com/api/oauth/token'; + + /** + * Creates an authorization header for Atlas API requests. + * Handles both OAuth 2.0 Bearer tokens and HTTP Digest authentication. + * + * @param orgId - The organization id for the Atlas credential instance + * @returns Authorization header value or undefined if no valid credentials + */ + public static async getAuthorizationHeader(orgId: string): Promise { + const credentials = AtlasCredentialCache.getAtlasCredentials(orgId); + if (!credentials) { + return undefined; + } + + switch (credentials.authType) { + case 'oauth': + return await this.getOAuthAuthorizationHeader(orgId, credentials); + // Don't need to set the digest header here as it is handled by the HTTP client directly + // case 'digest': + // return this.getDigestAuthorizationHeader(credentials); + default: + throw new Error(l10n.t('Unsupported Atlas authentication type: {0}', credentials.authType)); + } + } + + /** + * Gets Basic Authorization header for OAuth client credentials. + * Used for requesting access tokens from the OAuth endpoint. + * + * @param clientId - OAuth client ID + * @param clientSecret - OAuth client secret + * @returns Base64 encoded Basic auth header value + */ + public static getOAuthBasicAuthHeader(clientId: string, clientSecret: string): string { + const credentials = `${clientId}:${clientSecret}`; + const base64Credentials = Buffer.from(credentials, 'utf8').toString('base64'); + return `Basic ${base64Credentials}`; + } + + /** + * Requests a new OAuth access token from MongoDB Atlas. + * + * @param clientId - OAuth client ID + * @param clientSecret - OAuth client secret + * @returns Promise resolving to token response + */ + public static async requestOAuthToken(clientId: string, clientSecret: string): Promise { + const authHeader = this.getOAuthBasicAuthHeader(clientId, clientSecret); + + const response = await fetch(this.ATLAS_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Cache-Control': 'no-cache', + Authorization: authHeader, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=client_credentials', + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(l10n.t('Failed to obtain OAuth token: {0} {1}', response.status.toString(), errorText)); + } + + return response.json() as Promise; + } + + /** + * Clears Atlas authentication state for the given organization. + * + * @param orgId - The organization id for the Atlas credential instance + */ + public static clearAuthentication(orgId: string): void { + AtlasCredentialCache.clearAtlasCredentials(orgId); + } + + /** + * Gets or refreshes OAuth authorization header. + * Automatically handles token expiry and renewal. + */ + private static async getOAuthAuthorizationHeader(orgId: string, credentials: AtlasCredentials): Promise { + if (!credentials.oauth) { + throw new Error(l10n.t('OAuth credentials not found for organization {0}', orgId)); + } + + const { clientId, clientSecret } = credentials.oauth; + + // Check if we have a valid cached token + if (AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)) { + const cachedToken = credentials.oauth.accessToken; + if (cachedToken) { + return `Bearer ${cachedToken}`; + } + } + + // Request new token + const tokenResponse = await this.requestOAuthToken(clientId, clientSecret); + + // Update cache + AtlasCredentialCache.updateAtlasOAuthToken(orgId, tokenResponse.access_token, tokenResponse.expires_in); + + return `Bearer ${tokenResponse.access_token}`; + } + + // /** + // * Gets HTTP Digest authorization information. + // * Note: Actual digest authentication requires the server challenge, + // * so this returns the credentials for the HTTP client to use. + // */ + // private static getDigestAuthorizationHeader(credentials: AtlasCredentials): string { + // if (!credentials.digest) { + // throw new Error(l10n.t('Digest credentials not found')); + // } + + // // For digest auth, we return the credentials in a format that can be used + // // by the HTTP client library to generate the proper digest header + // const { publicKey, privateKey } = credentials.digest; + // return `Digest:${publicKey}:${privateKey}`; + // } +} diff --git a/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts b/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts new file mode 100644 index 000000000..38590e1db --- /dev/null +++ b/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CaseInsensitiveMap } from '../../../utils/CaseInsensitiveMap'; + +export interface AtlasCredentials { + /** Authentication type for Atlas */ + authType: 'oauth' | 'digest'; + + /** The unique identifier for the Atlas credential instance === the organization Id */ + orgId: string; + + /** OAuth 2.0 credentials */ + oauth?: { + clientId: string; + clientSecret: string; + // Token cache + accessToken?: string; + tokenExpiry?: number; // Unix timestamp + }; + + /** HTTP Digest credentials */ + digest?: { + publicKey: string; + privateKey: string; + }; +} + +export interface AtlasOrganizationCredentials { + /** The authentication credentials for the organization */ + credentials: AtlasCredentials; +} + +export class AtlasCredentialCache { + // the id of the organization === the orgId -> the atlas credentials + private static _store: CaseInsensitiveMap = new CaseInsensitiveMap(); + + /** + * Sets MongoDB Atlas OAuth 2.0 credentials for service discovery. + * + * @param orgId - The organization id for the Atlas credential instance + * @param clientId - OAuth client ID + * @param clientSecret - OAuth client secret + */ + public static setAtlasOAuthCredentials(orgId: string, clientId: string, clientSecret: string): void { + const existingCredentials = AtlasCredentialCache._store.get(orgId); + + const credentials: AtlasCredentials = { + ...existingCredentials, + orgId, + authType: 'oauth', + oauth: { + clientId, + clientSecret, + }, + digest: undefined, // Clear any existing digest credentials + }; + + AtlasCredentialCache._store.set(orgId, credentials); + } + + /** + * Sets MongoDB Atlas HTTP Digest credentials for service discovery. + * + * @param orgId - The organization id for the Atlas credential instance + * @param publicKey - Atlas API public key + * @param privateKey - Atlas API private key + */ + public static setAtlasDigestCredentials(orgId: string, publicKey: string, privateKey: string): void { + const existingCredentials = AtlasCredentialCache._store.get(orgId); + + const credentials: AtlasCredentials = { + ...existingCredentials, + orgId, + authType: 'digest', + digest: { + publicKey, + privateKey, + }, + oauth: undefined, // Clear any existing OAuth credentials + }; + + AtlasCredentialCache._store.set(orgId, credentials); + } + + /** + * Updates the OAuth access token cache for Atlas credentials. + * + * @param orgId - The organization id for the Atlas credential instance + * @param accessToken - The access token received from OAuth + * @param expiresInSeconds - Token lifetime in seconds + */ + public static updateAtlasOAuthToken(orgId: string, accessToken: string, expiresInSeconds: number = 3600): void { + const credentials = AtlasCredentialCache._store.get(orgId); + if (!credentials?.oauth) { + throw new Error(`No Atlas OAuth credentials found for organization ${orgId}`); + } + + const tokenExpiry = Date.now() + expiresInSeconds * 1000; + credentials.oauth.accessToken = accessToken; + credentials.oauth.tokenExpiry = tokenExpiry; + + AtlasCredentialCache._store.set(orgId, credentials); + } + + /** + * Gets Atlas credentials for a given cluster ID. + * + * @param orgId - The organization id for the Atlas credential instance + * @returns Atlas credentials or undefined if not found + */ + public static getAtlasCredentials(orgId: string): AtlasCredentials | undefined { + return AtlasCredentialCache._store.get(orgId); + } + + /** + * Checks if the OAuth token is still valid (not expired). + * + * @param orgId - The organization id for the Atlas credential instance + * @returns True if token exists and is valid, false otherwise + */ + public static isAtlasOAuthTokenValid(orgId: string): boolean { + const credentials = AtlasCredentialCache._store.get(orgId); + const oauth = credentials?.oauth; + + if (!oauth?.accessToken || !oauth.tokenExpiry) { + return false; + } + + // Add 60 second buffer to avoid edge cases + return Date.now() < oauth.tokenExpiry - 60000; + } + + /** + * Clears Atlas authentication state and removes credentials. + * + * @param orgId - The organization id for the Atlas credential instance + */ + public static clearAtlasCredentials(orgId: string): void { + const credentials = AtlasCredentialCache._store.get(orgId); + if (credentials) { + AtlasCredentialCache._store.delete(orgId); + } + } +} diff --git a/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts b/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts new file mode 100644 index 000000000..cff601b9a --- /dev/null +++ b/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import DigestClient from 'digest-fetch'; +import { AtlasAuthManager } from './AtlasAuthManager'; +import { type AtlasCredentials, AtlasCredentialCache } from './AtlasCredentialCache'; + +// Type definitions for digest-fetch since the library has incomplete types +interface DigestFetchClient { + fetch(url: string, options?: RequestInit): Promise; +} + +/** + * HTTP client for MongoDB Atlas API that handles authentication + */ +export class AtlasHttpClient { + private static readonly ATLAS_API_BASE_URL = 'https://cloud.mongodb.com/api/atlas/v2'; + + /** + * Makes an authenticated GET request to the Atlas API + * + * @param orgId - The organization id for the Atlas credential instance + * @param endpoint - API endpoint path (e.g., '/groups') + * @returns Response from Atlas API + */ + public static async get(orgId: string, endpoint: string): Promise { + return this.request(orgId, 'GET', endpoint); + } + + /** + * Makes an authenticated POST request to the Atlas API + * + * @param orgId - The organization id for the Atlas credential instance + * @param endpoint - API endpoint path (e.g., '/groups') + * @param body - Request body data + * @returns Response from Atlas API + */ + public static async post(orgId: string, endpoint: string, body?: unknown): Promise { + return this.request(orgId, 'POST', endpoint, body); + } + + /** + * Makes an authenticated DELETE request to the Atlas API + * + * @param orgId - The organization id for the Atlas credential instance + * @param endpoint - API endpoint path (e.g., '/groups/{id}') + * @returns Response from Atlas API + */ + public static async delete(orgId: string, endpoint: string): Promise { + return this.request(orgId, 'DELETE', endpoint); + } + + /** + * Makes an authenticated request to the Atlas API with proper authentication handling + */ + private static async request(orgId: string, method: string, endpoint: string, body?: unknown): Promise { + const credentials = AtlasCredentialCache.getAtlasCredentials(orgId); + if (!credentials) { + throw new Error(l10n.t('No Atlas credentials found for organization {0}', orgId)); + } + + const url = `${this.ATLAS_API_BASE_URL}${endpoint}`; + + switch (credentials.authType) { + case 'oauth': + return this.makeOAuthRequest(orgId, method, url, body); + case 'digest': + return this.makeDigestRequest(credentials, method, url, body); + default: + throw new Error(l10n.t('Unsupported Atlas authentication type: {0}', credentials.authType)); + } + } + + /** + * Makes OAuth authenticated request + */ + private static async makeOAuthRequest( + orgId: string, + method: string, + url: string, + body?: unknown, + ): Promise { + const authHeader = await AtlasAuthManager.getAuthorizationHeader(orgId); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new Error(l10n.t('Failed to obtain valid OAuth token')); + } + + const headers: Record = { + Authorization: authHeader, + 'Content-Type': 'application/json', + Accept: 'application/vnd.atlas.2023-02-01+json', + }; + + const requestInit: RequestInit = { + method, + headers, + }; + + if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { + requestInit.body = JSON.stringify(body); + } + + return fetch(url, requestInit); + } + + /** + * Makes HTTP Digest authenticated request. + * This implementation uses digest-fetch library for HTTP Digest authentication. + */ + private static async makeDigestRequest( + credentials: AtlasCredentials, + method: string, + url: string, + body?: unknown, + ): Promise { + if (!credentials.digest) { + throw new Error(l10n.t('Digest credentials not found')); + } + + const { publicKey, privateKey } = credentials.digest; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const client = new DigestClient(publicKey, privateKey) as DigestFetchClient; + + const requestInit: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/vnd.atlas.2024-08-05+json', + }, + }; + + if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { + requestInit.body = JSON.stringify(body); + } + + // Make digest authenticated request + const response = await client.fetch(url, requestInit); + + if (!response.ok) { + const errorText: string = await response.text(); + throw new Error(`Request failed with status ${response.status}: ${errorText}`); + } + + return response; + } +} From 9c0d76c7d86230b6f1db9a6253465e1ff65b0545 Mon Sep 17 00:00:00 2001 From: Xing Fan Date: Thu, 11 Sep 2025 14:21:08 +0000 Subject: [PATCH 02/10] more --- package-lock.json | 16 +++++++++---- .../utils/AtlasAdministrationClient.ts | 24 +++++++++---------- .../utils/AtlasAuthManager.ts | 18 -------------- .../utils/AtlasCredentialCache.ts | 8 ------- .../utils/AtlasHttpClient.ts | 9 +++---- 5 files changed, 29 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a8e3f026..d1e92d477 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8639,7 +8639,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": "*" @@ -9144,7 +9143,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": "*" @@ -12374,7 +12372,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true, "license": "MIT" }, "node_modules/is-callable": { @@ -13631,6 +13628,18 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-sha256": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", + "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==", + "license": "MIT" + }, + "node_modules/js-sha512": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha512/-/js-sha512-0.8.0.tgz", + "integrity": "sha512-PWsmefG6Jkodqt+ePTvBZCSMFgN7Clckjd0O7su3I0+BW2QWUTJNzjktHsztGLhncP2h8mcF9V9Y2Ha59pAViQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -14138,7 +14147,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "charenc": "0.0.2", diff --git a/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts index f66416e5c..52281c8a2 100644 --- a/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts +++ b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts @@ -9,7 +9,7 @@ import { AtlasHttpClient } from './AtlasHttpClient'; /** * Atlas project (group) information */ -export interface AtlasProject { +export type AtlasProject = { id?: string; name: string; orgId: string; @@ -25,12 +25,12 @@ export interface AtlasProject { value: string; }>; withDefaultAlertsSettings?: boolean; -} +}; /** * Atlas cluster information */ -export interface AtlasCluster { +export type AtlasCluster = { id?: string; name?: string; groupId?: string; @@ -176,12 +176,12 @@ export interface AtlasCluster { terminationProtectionEnabled?: boolean; versionReleaseSystem?: 'LTS' | 'CONTINUOUS'; }>; -} +}; /** * Atlas database user information */ -export interface AtlasDatabaseUser { +export type AtlasDatabaseUser = { username: string; // Max length is 1024 databaseName: 'admin' | '$external'; groupId: string; @@ -208,12 +208,12 @@ export interface AtlasDatabaseUser { deleteAfterDate?: string; // DATE-TIME description?: string; // Max 100 chars oidcAuthType?: 'NONE' | 'USER' | 'IDP_GROUP'; -} +}; /** * Atlas IP access list entry */ -export interface AtlasAccessListEntry { +export type AtlasAccessListEntry = { groupId: string; ipAddress?: string; cidrBlock?: string; @@ -224,30 +224,30 @@ export interface AtlasAccessListEntry { href: string; rel: string; }>; -} +}; /** * Response wrapper for paginated Atlas API responses */ -export interface AtlasApiResponse { +export type AtlasApiResponse = { results: T[]; totalCount: number; links?: Array<{ href: string; rel: string; }>; -} +}; /** * Parameters for creating IP access list entries */ -export interface CreateAccessListEntryParams { +export type CreateAccessListEntryParams = { ipAddress?: string; cidrBlock?: string; awsSecurityGroup?: string; comment?: string; deleteAfterDate?: string; -} +}; /** * MongoDB Atlas Administration Client for managing Atlas resources diff --git a/src/plugins/service-mongo-atlas/utils/AtlasAuthManager.ts b/src/plugins/service-mongo-atlas/utils/AtlasAuthManager.ts index c82cf6dc3..942a5f983 100644 --- a/src/plugins/service-mongo-atlas/utils/AtlasAuthManager.ts +++ b/src/plugins/service-mongo-atlas/utils/AtlasAuthManager.ts @@ -39,8 +39,6 @@ export class AtlasAuthManager { case 'oauth': return await this.getOAuthAuthorizationHeader(orgId, credentials); // Don't need to set the digest header here as it is handled by the HTTP client directly - // case 'digest': - // return this.getDigestAuthorizationHeader(credentials); default: throw new Error(l10n.t('Unsupported Atlas authentication type: {0}', credentials.authType)); } @@ -125,20 +123,4 @@ export class AtlasAuthManager { return `Bearer ${tokenResponse.access_token}`; } - - // /** - // * Gets HTTP Digest authorization information. - // * Note: Actual digest authentication requires the server challenge, - // * so this returns the credentials for the HTTP client to use. - // */ - // private static getDigestAuthorizationHeader(credentials: AtlasCredentials): string { - // if (!credentials.digest) { - // throw new Error(l10n.t('Digest credentials not found')); - // } - - // // For digest auth, we return the credentials in a format that can be used - // // by the HTTP client library to generate the proper digest header - // const { publicKey, privateKey } = credentials.digest; - // return `Digest:${publicKey}:${privateKey}`; - // } } diff --git a/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts b/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts index 38590e1db..27b4033ac 100644 --- a/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts +++ b/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts @@ -45,17 +45,13 @@ export class AtlasCredentialCache { * @param clientSecret - OAuth client secret */ public static setAtlasOAuthCredentials(orgId: string, clientId: string, clientSecret: string): void { - const existingCredentials = AtlasCredentialCache._store.get(orgId); - const credentials: AtlasCredentials = { - ...existingCredentials, orgId, authType: 'oauth', oauth: { clientId, clientSecret, }, - digest: undefined, // Clear any existing digest credentials }; AtlasCredentialCache._store.set(orgId, credentials); @@ -69,17 +65,13 @@ export class AtlasCredentialCache { * @param privateKey - Atlas API private key */ public static setAtlasDigestCredentials(orgId: string, publicKey: string, privateKey: string): void { - const existingCredentials = AtlasCredentialCache._store.get(orgId); - const credentials: AtlasCredentials = { - ...existingCredentials, orgId, authType: 'digest', digest: { publicKey, privateKey, }, - oauth: undefined, // Clear any existing OAuth credentials }; AtlasCredentialCache._store.set(orgId, credentials); diff --git a/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts b/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts index cff601b9a..6bb21dc6e 100644 --- a/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts +++ b/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts @@ -8,7 +8,7 @@ import DigestClient from 'digest-fetch'; import { AtlasAuthManager } from './AtlasAuthManager'; import { type AtlasCredentials, AtlasCredentialCache } from './AtlasCredentialCache'; -// Type definitions for digest-fetch since the library has incomplete types +// Type definitions for digest-fetch client interface DigestFetchClient { fetch(url: string, options?: RequestInit): Promise; } @@ -18,6 +18,7 @@ interface DigestFetchClient { */ export class AtlasHttpClient { private static readonly ATLAS_API_BASE_URL = 'https://cloud.mongodb.com/api/atlas/v2'; + private static readonly ATLAS_DIGEST_API_VERSION = 'application/vnd.atlas.2025-03-12+json'; /** * Makes an authenticated GET request to the Atlas API @@ -91,7 +92,7 @@ export class AtlasHttpClient { const headers: Record = { Authorization: authHeader, 'Content-Type': 'application/json', - Accept: 'application/vnd.atlas.2023-02-01+json', + Accept: this.ATLAS_DIGEST_API_VERSION, }; const requestInit: RequestInit = { @@ -121,14 +122,14 @@ export class AtlasHttpClient { } const { publicKey, privateKey } = credentials.digest; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const client = new DigestClient(publicKey, privateKey) as DigestFetchClient; const requestInit: RequestInit = { method, headers: { 'Content-Type': 'application/json', - Accept: 'application/vnd.atlas.2024-08-05+json', + Accept: this.ATLAS_DIGEST_API_VERSION, }, }; From 39692152744fe3a84e883f9cde43d3f5fb4ccd64 Mon Sep 17 00:00:00 2001 From: Xing Fan Date: Sun, 14 Sep 2025 07:21:58 +0000 Subject: [PATCH 03/10] tests --- jest.config.js | 2 +- .../AtlasAdministrationClient.test.ts | 104 ++++++++++++++++++ test/AtlasService/AtlasAuthManager.test.ts | 76 +++++++++++++ .../AtlasService/AtlasCredentialCache.test.ts | 63 +++++++++++ test/AtlasService/AtlasHttpClient.test.ts | 65 +++++++++++ 5 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 test/AtlasService/AtlasAdministrationClient.test.ts create mode 100644 test/AtlasService/AtlasAuthManager.test.ts create mode 100644 test/AtlasService/AtlasCredentialCache.test.ts create mode 100644 test/AtlasService/AtlasHttpClient.test.ts diff --git a/jest.config.js b/jest.config.js index a4de3e960..aa7101e88 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ /** @type {import('ts-jest').JestConfigWithTsJest} **/ module.exports = { testEnvironment: 'node', - testMatch: ['/src/**/*.test.ts'], + testMatch: ['/src/**/*.test.ts', '/test/AtlasService/**/*.test.ts'], transform: { '^.+.tsx?$': ['ts-jest', {}], }, diff --git a/test/AtlasService/AtlasAdministrationClient.test.ts b/test/AtlasService/AtlasAdministrationClient.test.ts new file mode 100644 index 000000000..f6ea4c39e --- /dev/null +++ b/test/AtlasService/AtlasAdministrationClient.test.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + AtlasAdministrationClient, + type AtlasApiResponse, +} from '../../src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient'; +import { AtlasHttpClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasHttpClient'; + +// Prevent ESM parsing issue from transitively importing digest-fetch by mocking it early. +jest.mock('digest-fetch', () => ({ default: jest.fn() })); +jest.mock('../../src/plugins/service-mongo-atlas/utils/AtlasHttpClient'); + +const mockedHttp = AtlasHttpClient as jest.Mocked; + +function mockJson(data: T) { + return { ok: true, status: 200, json: async () => data } as any as Response; +} + +function mockFail(status: number, text: string) { + return { ok: false, status, text: async () => text } as any as Response; +} + +describe('AtlasAdministrationClient', () => { + const orgId = 'org'; + const projectId = 'proj'; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('listProjects success builds query params', async () => { + const data: AtlasApiResponse = { + results: [{ name: 'p', orgId: orgId, created: '', clusterCount: 0 }], + totalCount: 1, + }; + mockedHttp.get.mockResolvedValue(mockJson(data)); + const resp = await AtlasAdministrationClient.listProjects(orgId, { + pageNum: 1, + itemsPerPage: 5, + includeCount: true, + }); + expect(resp.totalCount).toBe(1); + expect(mockedHttp.get.mock.calls[0][1]).toMatch(/pageNum=1/); + }); + + test('listProjects failure throws', async () => { + mockedHttp.get.mockResolvedValue(mockFail(500, 'err')); + await expect(AtlasAdministrationClient.listProjects(orgId)).rejects.toThrow(/Failed to list Atlas projects/); + }); + + test('listClusters success', async () => { + const data: AtlasApiResponse = { + results: [ + { + clusterType: 'REPLICASET', + providerSettings: { providerName: 'AWS', regionName: 'us', instanceSizeName: 'M10' }, + stateName: 'IDLE', + }, + ], + totalCount: 1, + }; + mockedHttp.get.mockResolvedValue(mockJson(data)); + const resp = await AtlasAdministrationClient.listClusters(orgId, projectId); + expect(resp.results.length).toBe(1); + }); + + test('getCluster failure throws', async () => { + mockedHttp.get.mockResolvedValue(mockFail(404, 'missing')); + await expect(AtlasAdministrationClient.getCluster(orgId, projectId, 'cl')).rejects.toThrow( + /Failed to get cluster/, + ); + }); + + test('listDatabaseUsers failure throws', async () => { + mockedHttp.get.mockResolvedValue(mockFail(400, 'bad')); + await expect(AtlasAdministrationClient.listDatabaseUsers(orgId, projectId)).rejects.toThrow( + /Failed to list database users/, + ); + }); + + test('getAccessList failure throws', async () => { + mockedHttp.get.mockResolvedValue(mockFail(401, 'unauth')); + await expect(AtlasAdministrationClient.getAccessList(orgId, projectId)).rejects.toThrow( + /Failed to get access list/, + ); + }); + + test('createAccessListEntries failure throws', async () => { + mockedHttp.post.mockResolvedValue(mockFail(500, 'boom')); + await expect(AtlasAdministrationClient.createAccessListEntries(orgId, projectId, [])).rejects.toThrow( + /Failed to create access list entries/, + ); + }); + + test('deleteAccessListEntry failure throws', async () => { + mockedHttp.delete.mockResolvedValue(mockFail(403, 'deny')); + await expect(AtlasAdministrationClient.deleteAccessListEntry(orgId, projectId, '1.1.1.1')).rejects.toThrow( + /Failed to delete access list entry/, + ); + }); +}); diff --git a/test/AtlasService/AtlasAuthManager.test.ts b/test/AtlasService/AtlasAuthManager.test.ts new file mode 100644 index 000000000..765d5ff59 --- /dev/null +++ b/test/AtlasService/AtlasAuthManager.test.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AtlasAuthManager } from '../../src/plugins/service-mongo-atlas/utils/AtlasAuthManager'; +import { AtlasCredentialCache } from '../../src/plugins/service-mongo-atlas/utils/AtlasCredentialCache'; + +// Mock global fetch +const globalAny: any = global; + +describe('AtlasAuthManager', () => { + const orgId = 'authOrg'; + const clientId = 'client'; + const clientSecret = 'secret'; + + beforeEach(() => { + jest.resetAllMocks(); + delete globalAny.fetch; + }); + + afterEach(() => { + AtlasCredentialCache.clearAtlasCredentials(orgId); + }); + + test('getOAuthBasicAuthHeader encodes credentials', () => { + const hdr = AtlasAuthManager.getOAuthBasicAuthHeader('id', 'sec'); + expect(hdr).toBe('Basic aWQ6c2Vj'); + }); + + test('requestOAuthToken success stores nothing automatically', async () => { + globalAny.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ access_token: 'tok', expires_in: 100, token_type: 'Bearer' }), + }); + const resp = await AtlasAuthManager.requestOAuthToken(clientId, clientSecret); + expect(resp.access_token).toBe('tok'); + expect(globalAny.fetch).toHaveBeenCalled(); + }); + + test('requestOAuthToken failure throws with status and text', async () => { + globalAny.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => 'bad request', + }); + await expect(AtlasAuthManager.requestOAuthToken(clientId, clientSecret)).rejects.toThrow(/400/); + }); + + test('getAuthorizationHeader returns bearer token using cache', async () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, clientId, clientSecret); + // add cached token + AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'cachedToken', 3600); + const hdr = await AtlasAuthManager.getAuthorizationHeader(orgId); + expect(hdr).toBe('Bearer cachedToken'); + }); + + test('getAuthorizationHeader fetches new token when expired', async () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, clientId, clientSecret); + // expired token + AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'old', -1); + globalAny.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ access_token: 'newToken', expires_in: 50, token_type: 'Bearer' }), + }); + const hdr = await AtlasAuthManager.getAuthorizationHeader(orgId); + expect(hdr).toBe('Bearer newToken'); + }); + + test('getAuthorizationHeader undefined when no credentials', async () => { + const hdr = await AtlasAuthManager.getAuthorizationHeader('missing'); + expect(hdr).toBeUndefined(); + }); +}); diff --git a/test/AtlasService/AtlasCredentialCache.test.ts b/test/AtlasService/AtlasCredentialCache.test.ts new file mode 100644 index 000000000..b3491fe5a --- /dev/null +++ b/test/AtlasService/AtlasCredentialCache.test.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AtlasCredentialCache } from '../../src/plugins/service-mongo-atlas/utils/AtlasCredentialCache'; + +// Helper to access private store for cleanup without exposing implementation +function clear(orgId: string) { + AtlasCredentialCache.clearAtlasCredentials(orgId); +} + +describe('AtlasCredentialCache', () => { + const orgId = 'OrgOne'; + const orgIdDifferentCase = 'orgone'; + + afterEach(() => { + clear(orgId); + }); + + test('set and get OAuth credentials (case insensitive key)', () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'clientId', 'clientSecret'); + const creds = AtlasCredentialCache.getAtlasCredentials(orgIdDifferentCase); + expect(creds).toBeDefined(); + expect(creds?.authType).toBe('oauth'); + expect(creds?.oauth?.clientId).toBe('clientId'); + }); + + test('set and get Digest credentials', () => { + AtlasCredentialCache.setAtlasDigestCredentials(orgId, 'public', 'private'); + const creds = AtlasCredentialCache.getAtlasCredentials(orgId); + expect(creds?.authType).toBe('digest'); + expect(creds?.digest?.publicKey).toBe('public'); + }); + + test('update token caches expiry and value', () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'client', 'secret'); + // use a larger expiry to pass buffer check ( >60s ) + AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'token123', 120); + const creds = AtlasCredentialCache.getAtlasCredentials(orgId)!; + expect(creds.oauth?.accessToken).toBe('token123'); + expect(creds.oauth?.tokenExpiry).toBeGreaterThan(Date.now()); + expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(true); + }); + + test('token validity false when missing token or expired', () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'client', 'secret'); + expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(false); + AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'token123', -1); // expired + expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(false); + }); + + test('clear credentials removes entry', () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'client', 'secret'); + expect(AtlasCredentialCache.getAtlasCredentials(orgId)).toBeDefined(); + AtlasCredentialCache.clearAtlasCredentials(orgId); + expect(AtlasCredentialCache.getAtlasCredentials(orgId)).toBeUndefined(); + }); + + test('updateAtlasOAuthToken throws if oauth creds missing', () => { + expect(() => AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'tkn')).toThrow(/No Atlas OAuth credentials/); + }); +}); diff --git a/test/AtlasService/AtlasHttpClient.test.ts b/test/AtlasService/AtlasHttpClient.test.ts new file mode 100644 index 000000000..99b3026bc --- /dev/null +++ b/test/AtlasService/AtlasHttpClient.test.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AtlasAuthManager } from '../../src/plugins/service-mongo-atlas/utils/AtlasAuthManager'; +import { AtlasCredentialCache } from '../../src/plugins/service-mongo-atlas/utils/AtlasCredentialCache'; +import { AtlasHttpClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasHttpClient'; + +// Mock digest-fetch module +jest.mock('digest-fetch', () => { + return jest.fn().mockImplementation(() => ({ + fetch: jest.fn(async () => ({ ok: true, status: 200, text: async () => '', json: async () => ({}) })), + })); +}); + +// Mock AtlasAuthManager +jest.mock('../../src/plugins/service-mongo-atlas/utils/AtlasAuthManager'); + +const mockedAuthManager = AtlasAuthManager as jest.Mocked; +const globalAny: any = global; + +describe('AtlasHttpClient', () => { + const orgId = 'org-http'; + + beforeEach(() => { + jest.resetAllMocks(); + delete globalAny.fetch; + AtlasCredentialCache.clearAtlasCredentials(orgId); + }); + + test('throws when no credentials', async () => { + await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(/No Atlas credentials/); + }); + + test('uses OAuth flow and sets Authorization header', async () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'cid', 'sec'); + mockedAuthManager.getAuthorizationHeader = jest.fn().mockResolvedValue('Bearer tokenX'); + const fetchSpy = jest.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ ok: true }) }); + globalAny.fetch = fetchSpy; + + await AtlasHttpClient.get(orgId, '/groups'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockedAuthManager.getAuthorizationHeader).toHaveBeenCalled(); + const headers = fetchSpy.mock.calls[0][1].headers; + expect(headers.Authorization).toBe('Bearer tokenX'); + }); + + test('oauth flow throws when missing bearer', async () => { + AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'cid', 'sec'); + mockedAuthManager.getAuthorizationHeader = jest.fn().mockResolvedValue('Invalid'); + await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(/Failed to obtain valid OAuth token/); + }); + + test('digest flow uses digest-fetch client and throws on non-ok', async () => { + AtlasCredentialCache.setAtlasDigestCredentials(orgId, 'pub', 'priv'); + // Override implementation to return failing response + const digestFetchModule = jest.requireMock('digest-fetch'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + digestFetchModule.mockImplementation(() => ({ + fetch: jest.fn(async () => ({ ok: false, status: 401, text: async () => 'Unauthorized' })), + })); + await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(/401/); + }); +}); From 7d8dd586823038b4d0848a7cfa4059265fd93787 Mon Sep 17 00:00:00 2001 From: Xing Fan Date: Sun, 14 Sep 2025 11:18:52 +0000 Subject: [PATCH 04/10] separate api types --- .../utils/AtlasAdminApiTypes.ts | 247 +++++++++++++++++ .../utils/AtlasAdministrationClient.ts | 251 +----------------- .../AtlasAdministrationClient.test.ts | 6 +- 3 files changed, 257 insertions(+), 247 deletions(-) create mode 100644 src/plugins/service-mongo-atlas/utils/AtlasAdminApiTypes.ts diff --git a/src/plugins/service-mongo-atlas/utils/AtlasAdminApiTypes.ts b/src/plugins/service-mongo-atlas/utils/AtlasAdminApiTypes.ts new file mode 100644 index 000000000..4c8ee1a70 --- /dev/null +++ b/src/plugins/service-mongo-atlas/utils/AtlasAdminApiTypes.ts @@ -0,0 +1,247 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Atlas project (group) information + */ +export type AtlasProject = { + id?: string; + name: string; + orgId: string; + created: string; + clusterCount: number; + links?: Array<{ + href: string; + rel: string; + }>; + regionUsageRestrictions?: 'COMMERCIAL_FEDRAMP_REGIONS_ONLY' | 'GOV_REGIONS_ONLY'; + tags?: Array<{ + key: string; + value: string; + }>; + withDefaultAlertsSettings?: boolean; +}; + +/** + * Atlas cluster information + */ +export type AtlasCluster = { + id?: string; + name?: string; + groupId?: string; + mongoDBMajorVersion?: string; + mongoDBVersion?: string; + clusterType: 'REPLICASET' | 'SHARDED' | 'GEOSHARDED'; + providerSettings: { + providerName: string; + regionName: string; + instanceSizeName: string; + }; + connectionStrings?: { + awsPrivateLink?: object; + awsPrivateLinkSrv?: object; + standard?: string; + standardSrv?: string; + private?: string; + privateEndpoint?: Array<{ + connectionString?: string; + endpoints?: Array<{ + endpointId?: string; + providerName?: 'AWS' | 'AZURE' | 'GCP'; + region?: string; + }>; + srvConnectionString?: string; + srvShardOptimizedConnectionString?: string; + type?: 'MONGOD' | 'MONGOS'; + }>; + privateSrv?: string; + }; + stateName: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'DELETED' | 'REPAIRING'; + createDate?: string; // DATE-TIME + links?: Array<{ + href: string; + rel: string; + }>; + acceptDataRisksAndForceReplicaSetReconfig?: string; // DATE-TIME + advancedConfiguration?: { + customOpensslCipherConfigTls12?: Array; + minimumEnabledTlsProtocol?: 'TLS1_0' | 'TLS1_1' | 'TLS1_2'; + tlsCipherConfigMode?: 'CUSTOM' | 'DEFAULT'; + }; + backupEnabled?: boolean; + biConnector?: { + enabled?: boolean; + readPreference?: 'PRIMARY' | 'SECONDARY' | 'ANALYTICS'; + }; + configServerManagementMode?: 'ATLAS_MANAGED' | 'FIXED_TO_DEDICATED'; + configServerType?: 'DEDICATED' | 'EMBEDDED'; + diskWarmingMode?: 'FULLY_WARMED' | 'VISIBLE_EARLIER'; + encryptionAtRestProvider?: 'AWS' | 'AZURE' | 'GCP' | 'NONE'; + featureCompatibilityVersion?: string; + featureCompatibilityVersionExpirationDate?: string; // DATE-TIME + globalClusterSelfManagedSharding?: boolean; + mongoDBEmployeeAccessGrant?: { + expirationTime: string; // DATE-TIME + grantType: + | 'CLUSTER_DATABASE_LOGS' + | 'CLUSTER_INFRASTRUCTURE' + | 'CLUSTER_INFRASTRUCTURE_AND_APP_SERVICES_SYNC_DATA'; + links?: Array<{ + href: string; + rel: string; + }>; + }; + paused?: boolean; + pitEnabled?: boolean; + redactClientLogData?: boolean; + replicaSetScalingStrategy?: 'SEQUENTIAL' | 'WORKLOAD_TYPE' | 'NODE_TYPE'; + replicationSpecs?: Array<{ + id?: string; + regionConfigs?: Array<{ + electableSpecs?: { + diskSizeGB?: number; // DOUBLE + diskIOPS?: number; // INTEGER + ebsVolumeType?: 'STANDARD' | 'PROVISIONED'; + instanceSize?: + | 'M10' + | 'M20' + | 'M30' + | 'M40' + | 'M50' + | 'M60' + | 'M80' + | 'M100' + | 'M140' + | 'M200' + | 'M300' + | 'R40' + | 'R50' + | 'R60' + | 'R80' + | 'R200' + | 'R300' + | 'R400' + | 'R700' + | 'M40_NVME' + | 'M50_NVME' + | 'M60_NVME' + | 'M80_NVME' + | 'M200_NVME' + | 'M400_NVME'; + nodeCount?: number; // INTEGER + }; + priority?: number; // INTEGER, Minimum 0, Maximum 7 + providerName?: 'AWS' | 'AZURE' | 'GCP' | 'TENANT'; // DISCRIMINATOR + regionName?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-tenant-object-regionname + analyticsAutoScaling?: { + compute?: { + enabled: boolean; + maxInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-maxinstancesize + minInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-mininstancesize + predictiveEnabled?: boolean; + scaleDownEnabled?: boolean; + }; + diskGB?: { + enabled?: boolean; + }; + }; + analyticsSpecs?: object; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsspecs + autoScaling?: { + compute?: { + enabled: boolean; + maxInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-maxinstancesize + minInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-mininstancesize + predictiveEnabled?: boolean; + scaleDownEnabled?: boolean; + }; + diskGB?: { + enabled?: boolean; + }; + }; + readOnlySpecs?: object; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-readonlyspecs + zoneId?: string; + zoneName?: string; + }>; + rootCertType?: 'ISRGROOTX1'; + stateName?: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'REPAIRING'; + tags?: Array<{ + key: string; + value: string; + }>; + terminationProtectionEnabled?: boolean; + versionReleaseSystem?: 'LTS' | 'CONTINUOUS'; + }>; +}; + +/** + * Atlas database user information + */ +export type AtlasDatabaseUser = { + username: string; // Max length is 1024 + databaseName: 'admin' | '$external'; + groupId: string; + roles: Array<{ + roleName: string; + databaseName: string; + collectionName?: string; + }>; + scopes?: Array<{ + name: string; + type: 'CLUSTER' | 'DATA_LAKE' | 'STREAM'; + }>; + labels?: Array<{ + key: string; + value: string; + }>; + ldapAuthType?: 'NONE' | 'USER' | 'GROUP'; + x509Type?: 'NONE' | 'MANAGED' | 'CUSTOMER'; + awsIAMType?: 'NONE' | 'USER' | 'ROLE'; + links?: Array<{ + href: string; + rel: string; + }>; + deleteAfterDate?: string; // DATE-TIME + description?: string; // Max 100 chars + oidcAuthType?: 'NONE' | 'USER' | 'IDP_GROUP'; +}; + +/** + * Atlas IP access list entry + */ +export type AtlasAccessListEntry = { + groupId: string; + ipAddress?: string; + cidrBlock?: string; + awsSecurityGroup?: string; + comment?: string; + deleteAfterDate?: string; + links?: Array<{ + href: string; + rel: string; + }>; +}; + +/** + * Response wrapper for paginated Atlas API responses + */ +export type AtlasApiResponse = { + results: T[]; + totalCount: number; + links?: Array<{ + href: string; + rel: string; + }>; +}; + +/** + * Parameters for creating IP access list entries + */ +export type CreateAccessListEntryParams = { + ipAddress?: string; + cidrBlock?: string; + awsSecurityGroup?: string; + comment?: string; + deleteAfterDate?: string; +}; diff --git a/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts index 52281c8a2..17d67ca65 100644 --- a/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts +++ b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts @@ -4,251 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as l10n from '@vscode/l10n'; +import { + type AtlasAccessListEntry, + type AtlasApiResponse, + type AtlasCluster, + type AtlasDatabaseUser, + type AtlasProject, + type CreateAccessListEntryParams, +} from './AtlasAdminApiTypes'; import { AtlasHttpClient } from './AtlasHttpClient'; -/** - * Atlas project (group) information - */ -export type AtlasProject = { - id?: string; - name: string; - orgId: string; - created: string; - clusterCount: number; - links?: Array<{ - href: string; - rel: string; - }>; - regionUsageRestrictions?: 'COMMERCIAL_FEDRAMP_REGIONS_ONLY' | 'GOV_REGIONS_ONLY'; - tags?: Array<{ - key: string; - value: string; - }>; - withDefaultAlertsSettings?: boolean; -}; - -/** - * Atlas cluster information - */ -export type AtlasCluster = { - id?: string; - name?: string; - groupId?: string; - mongoDBMajorVersion?: string; - mongoDBVersion?: string; - clusterType: 'REPLICASET' | 'SHARDED' | 'GEOSHARDED'; - providerSettings: { - providerName: string; - regionName: string; - instanceSizeName: string; - }; - connectionStrings?: { - awsPrivateLink?: object; - awsPrivateLinkSrv?: object; - standard?: string; - standardSrv?: string; - private?: string; - privateEndpoint?: Array<{ - connectionString?: string; - endpoints?: Array<{ - endpointId?: string; - providerName?: 'AWS' | 'AZURE' | 'GCP'; - region?: string; - }>; - srvConnectionString?: string; - srvShardOptimizedConnectionString?: string; - type?: 'MONGOD' | 'MONGOS'; - }>; - privateSrv?: string; - }; - stateName: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'DELETED' | 'REPAIRING'; - createDate?: string; // DATE-TIME - links?: Array<{ - href: string; - rel: string; - }>; - acceptDataRisksAndForceReplicaSetReconfig?: string; // DATE-TIME - advancedConfiguration?: { - customOpensslCipherConfigTls12?: Array; - minimumEnabledTlsProtocol?: 'TLS1_0' | 'TLS1_1' | 'TLS1_2'; - tlsCipherConfigMode?: 'CUSTOM' | 'DEFAULT'; - }; - backupEnabled?: boolean; - biConnector?: { - enabled?: boolean; - readPreference?: 'PRIMARY' | 'SECONDARY' | 'ANALYTICS'; - }; - configServerManagementMode?: 'ATLAS_MANAGED' | 'FIXED_TO_DEDICATED'; - configServerType?: 'DEDICATED' | 'EMBEDDED'; - diskWarmingMode?: 'FULLY_WARMED' | 'VISIBLE_EARLIER'; - encryptionAtRestProvider?: 'AWS' | 'AZURE' | 'GCP' | 'NONE'; - featureCompatibilityVersion?: string; - featureCompatibilityVersionExpirationDate?: string; // DATE-TIME - globalClusterSelfManagedSharding?: boolean; - mongoDBEmployeeAccessGrant?: { - expirationTime: string; // DATE-TIME - grantType: - | 'CLUSTER_DATABASE_LOGS' - | 'CLUSTER_INFRASTRUCTURE' - | 'CLUSTER_INFRASTRUCTURE_AND_APP_SERVICES_SYNC_DATA'; - links?: Array<{ - href: string; - rel: string; - }>; - }; - paused?: boolean; - pitEnabled?: boolean; - redactClientLogData?: boolean; - replicaSetScalingStrategy?: 'SEQUENTIAL' | 'WORKLOAD_TYPE' | 'NODE_TYPE'; - replicationSpecs?: Array<{ - id?: string; - regionConfigs?: Array<{ - electableSpecs?: { - diskSizeGB?: number; // DOUBLE - diskIOPS?: number; // INTEGER - ebsVolumeType?: 'STANDARD' | 'PROVISIONED'; - instanceSize?: - | 'M10' - | 'M20' - | 'M30' - | 'M40' - | 'M50' - | 'M60' - | 'M80' - | 'M100' - | 'M140' - | 'M200' - | 'M300' - | 'R40' - | 'R50' - | 'R60' - | 'R80' - | 'R200' - | 'R300' - | 'R400' - | 'R700' - | 'M40_NVME' - | 'M50_NVME' - | 'M60_NVME' - | 'M80_NVME' - | 'M200_NVME' - | 'M400_NVME'; - nodeCount?: number; // INTEGER - }; - priority?: number; // INTEGER, Minimum 0, Maximum 7 - providerName?: 'AWS' | 'AZURE' | 'GCP' | 'TENANT'; // DISCRIMINATOR - regionName?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-tenant-object-regionname - analyticsAutoScaling?: { - compute?: { - enabled: boolean; - maxInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-maxinstancesize - minInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-mininstancesize - predictiveEnabled?: boolean; - scaleDownEnabled?: boolean; - }; - diskGB?: { - enabled?: boolean; - }; - }; - analyticsSpecs?: object; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsspecs - autoScaling?: { - compute?: { - enabled: boolean; - maxInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-maxinstancesize - minInstanceSize?: string; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-analyticsautoscaling-compute-mininstancesize - predictiveEnabled?: boolean; - scaleDownEnabled?: boolean; - }; - diskGB?: { - enabled?: boolean; - }; - }; - readOnlySpecs?: object; // Options are provider-specific: https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/operation/operation-listgroupclusters#operation-listgroupclusters-200-body-application-vnd-atlas-2024-08-05-json-results-replicationspecs-regionconfigs-readonlyspecs - zoneId?: string; - zoneName?: string; - }>; - rootCertType?: 'ISRGROOTX1'; - stateName?: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'REPAIRING'; - tags?: Array<{ - key: string; - value: string; - }>; - terminationProtectionEnabled?: boolean; - versionReleaseSystem?: 'LTS' | 'CONTINUOUS'; - }>; -}; - -/** - * Atlas database user information - */ -export type AtlasDatabaseUser = { - username: string; // Max length is 1024 - databaseName: 'admin' | '$external'; - groupId: string; - roles: Array<{ - roleName: string; - databaseName: string; - collectionName?: string; - }>; - scopes?: Array<{ - name: string; - type: 'CLUSTER' | 'DATA_LAKE' | 'STREAM'; - }>; - labels?: Array<{ - key: string; - value: string; - }>; - ldapAuthType?: 'NONE' | 'USER' | 'GROUP'; - x509Type?: 'NONE' | 'MANAGED' | 'CUSTOMER'; - awsIAMType?: 'NONE' | 'USER' | 'ROLE'; - links?: Array<{ - href: string; - rel: string; - }>; - deleteAfterDate?: string; // DATE-TIME - description?: string; // Max 100 chars - oidcAuthType?: 'NONE' | 'USER' | 'IDP_GROUP'; -}; - -/** - * Atlas IP access list entry - */ -export type AtlasAccessListEntry = { - groupId: string; - ipAddress?: string; - cidrBlock?: string; - awsSecurityGroup?: string; - comment?: string; - deleteAfterDate?: string; - links?: Array<{ - href: string; - rel: string; - }>; -}; - -/** - * Response wrapper for paginated Atlas API responses - */ -export type AtlasApiResponse = { - results: T[]; - totalCount: number; - links?: Array<{ - href: string; - rel: string; - }>; -}; - -/** - * Parameters for creating IP access list entries - */ -export type CreateAccessListEntryParams = { - ipAddress?: string; - cidrBlock?: string; - awsSecurityGroup?: string; - comment?: string; - deleteAfterDate?: string; -}; - /** * MongoDB Atlas Administration Client for managing Atlas resources * Provides methods for projects, clusters, database users, and IP access lists diff --git a/test/AtlasService/AtlasAdministrationClient.test.ts b/test/AtlasService/AtlasAdministrationClient.test.ts index f6ea4c39e..44d845c17 100644 --- a/test/AtlasService/AtlasAdministrationClient.test.ts +++ b/test/AtlasService/AtlasAdministrationClient.test.ts @@ -3,10 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - AtlasAdministrationClient, - type AtlasApiResponse, -} from '../../src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient'; +import { type AtlasApiResponse } from '../../src/plugins/service-mongo-atlas/utils/AtlasAdminApiTypes'; +import { AtlasAdministrationClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient'; import { AtlasHttpClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasHttpClient'; // Prevent ESM parsing issue from transitively importing digest-fetch by mocking it early. From 5db75ab2a1553791ac1feedf99e5fc38059a9638 Mon Sep 17 00:00:00 2001 From: Xing Fan Date: Sun, 14 Sep 2025 12:03:22 +0000 Subject: [PATCH 05/10] update test --- .../types/AtlasAdminApiTypes.ts | 213 ++++++++++++++++++ .../utils/AtlasAdministrationClient.ts | 2 +- .../AtlasAdministrationClient.test.ts | 76 ++++--- test/AtlasService/AtlasAuthManager.test.ts | 47 ++-- .../AtlasService/AtlasCredentialCache.test.ts | 31 +-- test/AtlasService/AtlasHttpClient.test.ts | 69 +++--- 6 files changed, 347 insertions(+), 91 deletions(-) create mode 100644 src/plugins/service-mongo-atlas/types/AtlasAdminApiTypes.ts diff --git a/src/plugins/service-mongo-atlas/types/AtlasAdminApiTypes.ts b/src/plugins/service-mongo-atlas/types/AtlasAdminApiTypes.ts new file mode 100644 index 000000000..08e7fb0f3 --- /dev/null +++ b/src/plugins/service-mongo-atlas/types/AtlasAdminApiTypes.ts @@ -0,0 +1,213 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Centralized type contracts for MongoDB Atlas Administration API responses. +// These were originally defined in AtlasAdministrationClient.ts and moved out for reuse and clarity. + +export interface AtlasProject { + readonly id?: string; + readonly name: string; + readonly orgId: string; + readonly created: string; + readonly clusterCount: number; + readonly links?: Array<{ + readonly href: string; + readonly rel: string; + }>; + readonly regionUsageRestrictions?: 'COMMERCIAL_FEDRAMP_REGIONS_ONLY' | 'GOV_REGIONS_ONLY'; + readonly tags?: Array<{ + readonly key: string; + readonly value: string; + }>; + readonly withDefaultAlertsSettings?: boolean; +} + +export interface AtlasCluster { + readonly id?: string; + readonly name?: string; + readonly groupId?: string; + readonly mongoDBMajorVersion?: string; + readonly mongoDBVersion?: string; + readonly clusterType: 'REPLICASET' | 'SHARDED' | 'GEOSHARDED'; + readonly providerSettings: { + readonly providerName: string; + readonly regionName: string; + readonly instanceSizeName: string; + }; + readonly connectionStrings?: { + readonly awsPrivateLink?: object; + readonly awsPrivateLinkSrv?: object; + readonly standard?: string; + readonly standardSrv?: string; + readonly private?: string; + readonly privateEndpoint?: Array<{ + readonly connectionString?: string; + readonly endpoints?: Array<{ + readonly endpointId?: string; + readonly providerName?: 'AWS' | 'AZURE' | 'GCP'; + readonly region?: string; + }>; + readonly srvConnectionString?: string; + readonly srvShardOptimizedConnectionString?: string; + readonly type?: 'MONGOD' | 'MONGOS'; + }>; + readonly privateSrv?: string; + }; + readonly stateName: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'DELETED' | 'REPAIRING'; + readonly createDate?: string; // DATE-TIME + readonly links?: Array<{ + readonly href: string; + readonly rel: string; + }>; + readonly acceptDataRisksAndForceReplicaSetReconfig?: string; // DATE-TIME + readonly advancedConfiguration?: { + readonly customOpensslCipherConfigTls12?: Array; + readonly minimumEnabledTlsProtocol?: 'TLS1_0' | 'TLS1_1' | 'TLS1_2'; + readonly tlsCipherConfigMode?: 'CUSTOM' | 'DEFAULT'; + }; + readonly backupEnabled?: boolean; + readonly biConnector?: { + readonly enabled?: boolean; + readonly readPreference?: 'PRIMARY' | 'SECONDARY' | 'ANALYTICS'; + }; + readonly configServerManagementMode?: 'ATLAS_MANAGED' | 'FIXED_TO_DEDICATED'; + readonly configServerType?: 'DEDICATED' | 'EMBEDDED'; + readonly diskWarmingMode?: 'FULLY_WARMED' | 'VISIBLE_EARLIER'; + readonly encryptionAtRestProvider?: 'AWS' | 'AZURE' | 'GCP' | 'NONE'; + readonly featureCompatibilityVersion?: string; + readonly featureCompatibilityVersionExpirationDate?: string; // DATE-TIME + readonly globalClusterSelfManagedSharding?: boolean; + readonly mongoDBEmployeeAccessGrant?: { + readonly expirationTime: string; // DATE-TIME + readonly grantType: + | 'CLUSTER_DATABASE_LOGS' + | 'CLUSTER_INFRASTRUCTURE' + | 'CLUSTER_INFRASTRUCTURE_AND_APP_SERVICES_SYNC_DATA'; + readonly links?: Array<{ + readonly href: string; + readonly rel: string; + }>; + }; + readonly paused?: boolean; + readonly pitEnabled?: boolean; + readonly redactClientLogData?: boolean; + readonly replicaSetScalingStrategy?: 'SEQUENTIAL' | 'WORKLOAD_TYPE' | 'NODE_TYPE'; + readonly replicationSpecs?: Array<{ + readonly id?: string; + readonly regionConfigs?: Array<{ + readonly electableSpecs?: { + readonly diskSizeGB?: number; // DOUBLE + readonly diskIOPS?: number; // INTEGER + readonly ebsVolumeType?: 'STANDARD' | 'PROVISIONED'; + readonly instanceSize?: + | 'M10' + | 'M20' + | 'M30' + | 'M40' + | 'M50' + | 'M60' + | 'M80' + | 'M100' + | 'M140' + | 'M200' + | 'M300' + | 'R40' + | 'R50' + | 'R60' + | 'R80' + | 'R200' + | 'R300' + | 'R400' + | 'R700' + | 'M40_NVME' + | 'M50_NVME' + | 'M60_NVME' + | 'M80_NVME' + | 'M200_NVME' + | 'M400_NVME'; + readonly nodeCount?: number; // INTEGER + }; + readonly priority?: number; // 0-7 + readonly providerName?: 'AWS' | 'AZURE' | 'GCP' | 'TENANT'; + readonly regionName?: string; + readonly analyticsAutoScaling?: { + readonly compute?: { + readonly enabled: boolean; + readonly maxInstanceSize?: string; + readonly minInstanceSize?: string; + readonly predictiveEnabled?: boolean; + readonly scaleDownEnabled?: boolean; + }; + readonly diskGB?: { readonly enabled?: boolean }; + }; + readonly analyticsSpecs?: object; + readonly autoScaling?: { + readonly compute?: { + readonly enabled: boolean; + readonly maxInstanceSize?: string; + readonly minInstanceSize?: string; + readonly predictiveEnabled?: boolean; + readonly scaleDownEnabled?: boolean; + }; + readonly diskGB?: { readonly enabled?: boolean }; + }; + readonly readOnlySpecs?: object; + readonly zoneId?: string; + readonly zoneName?: string; + }>; + readonly rootCertType?: 'ISRGROOTX1'; + readonly stateName?: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'REPAIRING'; + readonly tags?: Array<{ readonly key: string; readonly value: string }>; + readonly terminationProtectionEnabled?: boolean; + readonly versionReleaseSystem?: 'LTS' | 'CONTINUOUS'; + }>; +} + +export interface AtlasDatabaseUser { + readonly username: string; // Max 1024 + readonly databaseName: 'admin' | '$external'; + readonly groupId: string; + readonly roles: Array<{ + readonly roleName: string; + readonly databaseName: string; + readonly collectionName?: string; + }>; + readonly scopes?: Array<{ + readonly name: string; + readonly type: 'CLUSTER' | 'DATA_LAKE' | 'STREAM'; + }>; + readonly labels?: Array<{ readonly key: string; readonly value: string }>; + readonly ldapAuthType?: 'NONE' | 'USER' | 'GROUP'; + readonly x509Type?: 'NONE' | 'MANAGED' | 'CUSTOMER'; + readonly awsIAMType?: 'NONE' | 'USER' | 'ROLE'; + readonly links?: Array<{ readonly href: string; readonly rel: string }>; + readonly deleteAfterDate?: string; // DATE-TIME + readonly description?: string; // Max 100 + readonly oidcAuthType?: 'NONE' | 'USER' | 'IDP_GROUP'; +} + +export interface AtlasAccessListEntry { + readonly groupId: string; + readonly ipAddress?: string; + readonly cidrBlock?: string; + readonly awsSecurityGroup?: string; + readonly comment?: string; + readonly deleteAfterDate?: string; + readonly links?: Array<{ readonly href: string; readonly rel: string }>; +} + +export interface AtlasApiResponse { + readonly results: T[]; + readonly totalCount: number; + readonly links?: Array<{ readonly href: string; readonly rel: string }>; +} + +export interface CreateAccessListEntryParams { + readonly ipAddress?: string; + readonly cidrBlock?: string; + readonly awsSecurityGroup?: string; + readonly comment?: string; + readonly deleteAfterDate?: string; +} diff --git a/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts index 17d67ca65..b7a5222a4 100644 --- a/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts +++ b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts @@ -11,7 +11,7 @@ import { type AtlasDatabaseUser, type AtlasProject, type CreateAccessListEntryParams, -} from './AtlasAdminApiTypes'; +} from '../types/AtlasAdminApiTypes'; import { AtlasHttpClient } from './AtlasHttpClient'; /** diff --git a/test/AtlasService/AtlasAdministrationClient.test.ts b/test/AtlasService/AtlasAdministrationClient.test.ts index 44d845c17..0a2d91c20 100644 --- a/test/AtlasService/AtlasAdministrationClient.test.ts +++ b/test/AtlasService/AtlasAdministrationClient.test.ts @@ -3,30 +3,43 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import assert from 'assert'; import { type AtlasApiResponse } from '../../src/plugins/service-mongo-atlas/utils/AtlasAdminApiTypes'; import { AtlasAdministrationClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient'; import { AtlasHttpClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasHttpClient'; -// Prevent ESM parsing issue from transitively importing digest-fetch by mocking it early. -jest.mock('digest-fetch', () => ({ default: jest.fn() })); -jest.mock('../../src/plugins/service-mongo-atlas/utils/AtlasHttpClient'); +// We'll manually stub AtlasHttpClient methods -const mockedHttp = AtlasHttpClient as jest.Mocked; - -function mockJson(data: T) { +function mockJson(data: T): Response { return { ok: true, status: 200, json: async () => data } as any as Response; } -function mockFail(status: number, text: string) { +function mockFail(status: number, text: string): Response { return { ok: false, status, text: async () => text } as any as Response; } -describe('AtlasAdministrationClient', () => { +suite('AtlasAdministrationClient', () => { const orgId = 'org'; const projectId = 'proj'; - beforeEach(() => { - jest.resetAllMocks(); + let originalGet: typeof AtlasHttpClient.get; + let originalPost: typeof AtlasHttpClient.post; + let originalDelete: typeof AtlasHttpClient.delete; + + function resetStubs() { + AtlasHttpClient.get = originalGet; + AtlasHttpClient.post = originalPost; + AtlasHttpClient.delete = originalDelete; + } + + setup(() => { + originalGet = AtlasHttpClient.get.bind(AtlasHttpClient); + originalPost = AtlasHttpClient.post.bind(AtlasHttpClient); + originalDelete = AtlasHttpClient.delete.bind(AtlasHttpClient); + }); + + teardown(() => { + resetStubs(); }); test('listProjects success builds query params', async () => { @@ -34,19 +47,23 @@ describe('AtlasAdministrationClient', () => { results: [{ name: 'p', orgId: orgId, created: '', clusterCount: 0 }], totalCount: 1, }; - mockedHttp.get.mockResolvedValue(mockJson(data)); + let calledEndpoint = ''; + AtlasHttpClient.get = (async (_org, endpoint) => { + calledEndpoint = endpoint; + return mockJson(data); + }) as any; const resp = await AtlasAdministrationClient.listProjects(orgId, { pageNum: 1, itemsPerPage: 5, includeCount: true, }); - expect(resp.totalCount).toBe(1); - expect(mockedHttp.get.mock.calls[0][1]).toMatch(/pageNum=1/); + assert.strictEqual(resp.totalCount, 1); + assert.ok(/pageNum=1/.test(calledEndpoint), 'Expected pageNum query param'); }); test('listProjects failure throws', async () => { - mockedHttp.get.mockResolvedValue(mockFail(500, 'err')); - await expect(AtlasAdministrationClient.listProjects(orgId)).rejects.toThrow(/Failed to list Atlas projects/); + AtlasHttpClient.get = (async () => mockFail(500, 'err')) as any; + await assert.rejects(() => AtlasAdministrationClient.listProjects(orgId), /Failed to list Atlas projects/); }); test('listClusters success', async () => { @@ -60,42 +77,47 @@ describe('AtlasAdministrationClient', () => { ], totalCount: 1, }; - mockedHttp.get.mockResolvedValue(mockJson(data)); + AtlasHttpClient.get = (async () => mockJson(data)) as any; const resp = await AtlasAdministrationClient.listClusters(orgId, projectId); - expect(resp.results.length).toBe(1); + assert.strictEqual(resp.results.length, 1); }); test('getCluster failure throws', async () => { - mockedHttp.get.mockResolvedValue(mockFail(404, 'missing')); - await expect(AtlasAdministrationClient.getCluster(orgId, projectId, 'cl')).rejects.toThrow( + AtlasHttpClient.get = (async () => mockFail(404, 'missing')) as any; + await assert.rejects( + () => AtlasAdministrationClient.getCluster(orgId, projectId, 'cl'), /Failed to get cluster/, ); }); test('listDatabaseUsers failure throws', async () => { - mockedHttp.get.mockResolvedValue(mockFail(400, 'bad')); - await expect(AtlasAdministrationClient.listDatabaseUsers(orgId, projectId)).rejects.toThrow( + AtlasHttpClient.get = (async () => mockFail(400, 'bad')) as any; + await assert.rejects( + () => AtlasAdministrationClient.listDatabaseUsers(orgId, projectId), /Failed to list database users/, ); }); test('getAccessList failure throws', async () => { - mockedHttp.get.mockResolvedValue(mockFail(401, 'unauth')); - await expect(AtlasAdministrationClient.getAccessList(orgId, projectId)).rejects.toThrow( + AtlasHttpClient.get = (async () => mockFail(401, 'unauth')) as any; + await assert.rejects( + () => AtlasAdministrationClient.getAccessList(orgId, projectId), /Failed to get access list/, ); }); test('createAccessListEntries failure throws', async () => { - mockedHttp.post.mockResolvedValue(mockFail(500, 'boom')); - await expect(AtlasAdministrationClient.createAccessListEntries(orgId, projectId, [])).rejects.toThrow( + AtlasHttpClient.post = (async () => mockFail(500, 'boom')) as any; + await assert.rejects( + () => AtlasAdministrationClient.createAccessListEntries(orgId, projectId, []), /Failed to create access list entries/, ); }); test('deleteAccessListEntry failure throws', async () => { - mockedHttp.delete.mockResolvedValue(mockFail(403, 'deny')); - await expect(AtlasAdministrationClient.deleteAccessListEntry(orgId, projectId, '1.1.1.1')).rejects.toThrow( + AtlasHttpClient.delete = (async () => mockFail(403, 'deny')) as any; + await assert.rejects( + () => AtlasAdministrationClient.deleteAccessListEntry(orgId, projectId, '1.1.1.1'), /Failed to delete access list entry/, ); }); diff --git a/test/AtlasService/AtlasAuthManager.test.ts b/test/AtlasService/AtlasAuthManager.test.ts index 765d5ff59..c355a3381 100644 --- a/test/AtlasService/AtlasAuthManager.test.ts +++ b/test/AtlasService/AtlasAuthManager.test.ts @@ -3,49 +3,59 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import assert from 'assert'; import { AtlasAuthManager } from '../../src/plugins/service-mongo-atlas/utils/AtlasAuthManager'; import { AtlasCredentialCache } from '../../src/plugins/service-mongo-atlas/utils/AtlasCredentialCache'; // Mock global fetch const globalAny: any = global; -describe('AtlasAuthManager', () => { +type FetchFn = (url: string, init?: any) => Promise; + +suite('AtlasAuthManager', () => { const orgId = 'authOrg'; const clientId = 'client'; const clientSecret = 'secret'; + let originalFetch: unknown; - beforeEach(() => { - jest.resetAllMocks(); + setup(() => { + originalFetch = globalAny.fetch; delete globalAny.fetch; }); - afterEach(() => { + teardown(() => { AtlasCredentialCache.clearAtlasCredentials(orgId); + if (originalFetch) { + globalAny.fetch = originalFetch; + } else { + delete globalAny.fetch; + } }); test('getOAuthBasicAuthHeader encodes credentials', () => { const hdr = AtlasAuthManager.getOAuthBasicAuthHeader('id', 'sec'); - expect(hdr).toBe('Basic aWQ6c2Vj'); + assert.strictEqual(hdr, 'Basic aWQ6c2Vj'); }); test('requestOAuthToken success stores nothing automatically', async () => { - globalAny.fetch = jest.fn().mockResolvedValue({ + const mockFetch: FetchFn = async () => ({ ok: true, status: 200, json: async () => ({ access_token: 'tok', expires_in: 100, token_type: 'Bearer' }), }); + globalAny.fetch = mockFetch; const resp = await AtlasAuthManager.requestOAuthToken(clientId, clientSecret); - expect(resp.access_token).toBe('tok'); - expect(globalAny.fetch).toHaveBeenCalled(); + assert.strictEqual(resp.access_token, 'tok'); }); test('requestOAuthToken failure throws with status and text', async () => { - globalAny.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 400, - text: async () => 'bad request', - }); - await expect(AtlasAuthManager.requestOAuthToken(clientId, clientSecret)).rejects.toThrow(/400/); + const mockFetch: FetchFn = async () => ({ ok: false, status: 400, text: async () => 'bad request' }); + globalAny.fetch = mockFetch; + await assert.rejects( + () => AtlasAuthManager.requestOAuthToken(clientId, clientSecret), + /400/, + 'Should include status code 400', + ); }); test('getAuthorizationHeader returns bearer token using cache', async () => { @@ -53,24 +63,25 @@ describe('AtlasAuthManager', () => { // add cached token AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'cachedToken', 3600); const hdr = await AtlasAuthManager.getAuthorizationHeader(orgId); - expect(hdr).toBe('Bearer cachedToken'); + assert.strictEqual(hdr, 'Bearer cachedToken'); }); test('getAuthorizationHeader fetches new token when expired', async () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, clientId, clientSecret); // expired token AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'old', -1); - globalAny.fetch = jest.fn().mockResolvedValue({ + const mockFetch: FetchFn = async () => ({ ok: true, status: 200, json: async () => ({ access_token: 'newToken', expires_in: 50, token_type: 'Bearer' }), }); + globalAny.fetch = mockFetch; const hdr = await AtlasAuthManager.getAuthorizationHeader(orgId); - expect(hdr).toBe('Bearer newToken'); + assert.strictEqual(hdr, 'Bearer newToken'); }); test('getAuthorizationHeader undefined when no credentials', async () => { const hdr = await AtlasAuthManager.getAuthorizationHeader('missing'); - expect(hdr).toBeUndefined(); + assert.strictEqual(hdr, undefined); }); }); diff --git a/test/AtlasService/AtlasCredentialCache.test.ts b/test/AtlasService/AtlasCredentialCache.test.ts index b3491fe5a..9e4ae3c09 100644 --- a/test/AtlasService/AtlasCredentialCache.test.ts +++ b/test/AtlasService/AtlasCredentialCache.test.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import assert from 'assert'; import { AtlasCredentialCache } from '../../src/plugins/service-mongo-atlas/utils/AtlasCredentialCache'; // Helper to access private store for cleanup without exposing implementation @@ -10,27 +11,27 @@ function clear(orgId: string) { AtlasCredentialCache.clearAtlasCredentials(orgId); } -describe('AtlasCredentialCache', () => { +suite('AtlasCredentialCache', () => { const orgId = 'OrgOne'; const orgIdDifferentCase = 'orgone'; - afterEach(() => { + teardown(() => { clear(orgId); }); test('set and get OAuth credentials (case insensitive key)', () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'clientId', 'clientSecret'); const creds = AtlasCredentialCache.getAtlasCredentials(orgIdDifferentCase); - expect(creds).toBeDefined(); - expect(creds?.authType).toBe('oauth'); - expect(creds?.oauth?.clientId).toBe('clientId'); + assert.ok(creds, 'creds should be defined'); + assert.strictEqual(creds?.authType, 'oauth'); + assert.strictEqual(creds?.oauth?.clientId, 'clientId'); }); test('set and get Digest credentials', () => { AtlasCredentialCache.setAtlasDigestCredentials(orgId, 'public', 'private'); const creds = AtlasCredentialCache.getAtlasCredentials(orgId); - expect(creds?.authType).toBe('digest'); - expect(creds?.digest?.publicKey).toBe('public'); + assert.strictEqual(creds?.authType, 'digest'); + assert.strictEqual(creds?.digest?.publicKey, 'public'); }); test('update token caches expiry and value', () => { @@ -38,26 +39,26 @@ describe('AtlasCredentialCache', () => { // use a larger expiry to pass buffer check ( >60s ) AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'token123', 120); const creds = AtlasCredentialCache.getAtlasCredentials(orgId)!; - expect(creds.oauth?.accessToken).toBe('token123'); - expect(creds.oauth?.tokenExpiry).toBeGreaterThan(Date.now()); - expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(true); + assert.strictEqual(creds.oauth?.accessToken, 'token123'); + assert.ok((creds.oauth?.tokenExpiry ?? 0) > Date.now()); + assert.ok(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)); }); test('token validity false when missing token or expired', () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'client', 'secret'); - expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(false); + assert.ok(!AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)); AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'token123', -1); // expired - expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(false); + assert.ok(!AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)); }); test('clear credentials removes entry', () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'client', 'secret'); - expect(AtlasCredentialCache.getAtlasCredentials(orgId)).toBeDefined(); + assert.ok(AtlasCredentialCache.getAtlasCredentials(orgId)); AtlasCredentialCache.clearAtlasCredentials(orgId); - expect(AtlasCredentialCache.getAtlasCredentials(orgId)).toBeUndefined(); + assert.strictEqual(AtlasCredentialCache.getAtlasCredentials(orgId), undefined); }); test('updateAtlasOAuthToken throws if oauth creds missing', () => { - expect(() => AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'tkn')).toThrow(/No Atlas OAuth credentials/); + assert.throws(() => AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'tkn'), /No Atlas OAuth credentials/); }); }); diff --git a/test/AtlasService/AtlasHttpClient.test.ts b/test/AtlasService/AtlasHttpClient.test.ts index 99b3026bc..911463ca5 100644 --- a/test/AtlasService/AtlasHttpClient.test.ts +++ b/test/AtlasService/AtlasHttpClient.test.ts @@ -3,63 +3,72 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import assert from 'assert'; import { AtlasAuthManager } from '../../src/plugins/service-mongo-atlas/utils/AtlasAuthManager'; import { AtlasCredentialCache } from '../../src/plugins/service-mongo-atlas/utils/AtlasCredentialCache'; import { AtlasHttpClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasHttpClient'; -// Mock digest-fetch module -jest.mock('digest-fetch', () => { - return jest.fn().mockImplementation(() => ({ - fetch: jest.fn(async () => ({ ok: true, status: 200, text: async () => '', json: async () => ({}) })), - })); -}); - -// Mock AtlasAuthManager -jest.mock('../../src/plugins/service-mongo-atlas/utils/AtlasAuthManager'); - -const mockedAuthManager = AtlasAuthManager as jest.Mocked; +// We'll monkey-patch modules rather than using jest.mock const globalAny: any = global; -describe('AtlasHttpClient', () => { +type FetchFn = (url: string, init?: any) => Promise; + +suite('AtlasHttpClient', () => { const orgId = 'org-http'; + let originalFetch: unknown; + let originalGetAuthHeader: typeof AtlasAuthManager.getAuthorizationHeader; - beforeEach(() => { - jest.resetAllMocks(); + setup(() => { + originalFetch = globalAny.fetch; + originalGetAuthHeader = AtlasAuthManager.getAuthorizationHeader.bind( + AtlasAuthManager, + ) as unknown as typeof AtlasAuthManager.getAuthorizationHeader; delete globalAny.fetch; AtlasCredentialCache.clearAtlasCredentials(orgId); }); + teardown(() => { + if (originalFetch) { + globalAny.fetch = originalFetch; + } else { + delete globalAny.fetch; + } + AtlasAuthManager.getAuthorizationHeader = originalGetAuthHeader as any; + }); + test('throws when no credentials', async () => { - await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(/No Atlas credentials/); + await assert.rejects(() => AtlasHttpClient.get(orgId, '/groups'), /No Atlas credentials/); }); test('uses OAuth flow and sets Authorization header', async () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'cid', 'sec'); - mockedAuthManager.getAuthorizationHeader = jest.fn().mockResolvedValue('Bearer tokenX'); - const fetchSpy = jest.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ ok: true }) }); + let called = false; + AtlasAuthManager.getAuthorizationHeader = (async () => { + called = true; + return 'Bearer tokenX'; + }) as any; + const fetchSpyCalls: any[] = []; + const fetchSpy: FetchFn = async (_url, init) => { + fetchSpyCalls.push([_url, init]); + return { ok: true, status: 200, json: async () => ({ ok: true }) } as any; + }; globalAny.fetch = fetchSpy; await AtlasHttpClient.get(orgId, '/groups'); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(mockedAuthManager.getAuthorizationHeader).toHaveBeenCalled(); - const headers = fetchSpy.mock.calls[0][1].headers; - expect(headers.Authorization).toBe('Bearer tokenX'); + assert.ok(called, 'Expected getAuthorizationHeader to be called'); + const headers = fetchSpyCalls[0][1].headers; + assert.strictEqual(headers.Authorization, 'Bearer tokenX'); }); test('oauth flow throws when missing bearer', async () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'cid', 'sec'); - mockedAuthManager.getAuthorizationHeader = jest.fn().mockResolvedValue('Invalid'); - await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(/Failed to obtain valid OAuth token/); + AtlasAuthManager.getAuthorizationHeader = (async () => 'Invalid') as any; + await assert.rejects(() => AtlasHttpClient.get(orgId, '/groups'), /Failed to obtain valid OAuth token/); }); test('digest flow uses digest-fetch client and throws on non-ok', async () => { AtlasCredentialCache.setAtlasDigestCredentials(orgId, 'pub', 'priv'); - // Override implementation to return failing response - const digestFetchModule = jest.requireMock('digest-fetch'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - digestFetchModule.mockImplementation(() => ({ - fetch: jest.fn(async () => ({ ok: false, status: 401, text: async () => 'Unauthorized' })), - })); - await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(/401/); + globalAny.fetch = async () => ({ ok: false, status: 401, text: async () => 'Unauthorized' }); + await assert.rejects(() => AtlasHttpClient.get(orgId, '/groups')); }); }); From a3c357342df2b044fa024f4eca930fe6191367e7 Mon Sep 17 00:00:00 2001 From: Xing Fan Date: Sun, 14 Sep 2025 12:13:59 +0000 Subject: [PATCH 06/10] update jtest --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index aa7101e88..a4de3e960 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ /** @type {import('ts-jest').JestConfigWithTsJest} **/ module.exports = { testEnvironment: 'node', - testMatch: ['/src/**/*.test.ts', '/test/AtlasService/**/*.test.ts'], + testMatch: ['/src/**/*.test.ts'], transform: { '^.+.tsx?$': ['ts-jest', {}], }, From 3d22fe3d956124de090b757e8e447f8b35d8e17b Mon Sep 17 00:00:00 2001 From: Xing Fan Date: Sun, 14 Sep 2025 12:17:27 +0000 Subject: [PATCH 07/10] update comments --- test/AtlasService/AtlasAdministrationClient.test.ts | 2 -- test/AtlasService/AtlasHttpClient.test.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/test/AtlasService/AtlasAdministrationClient.test.ts b/test/AtlasService/AtlasAdministrationClient.test.ts index 0a2d91c20..6c14a7d78 100644 --- a/test/AtlasService/AtlasAdministrationClient.test.ts +++ b/test/AtlasService/AtlasAdministrationClient.test.ts @@ -8,8 +8,6 @@ import { type AtlasApiResponse } from '../../src/plugins/service-mongo-atlas/uti import { AtlasAdministrationClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient'; import { AtlasHttpClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasHttpClient'; -// We'll manually stub AtlasHttpClient methods - function mockJson(data: T): Response { return { ok: true, status: 200, json: async () => data } as any as Response; } diff --git a/test/AtlasService/AtlasHttpClient.test.ts b/test/AtlasService/AtlasHttpClient.test.ts index 911463ca5..f21b5acdb 100644 --- a/test/AtlasService/AtlasHttpClient.test.ts +++ b/test/AtlasService/AtlasHttpClient.test.ts @@ -8,7 +8,6 @@ import { AtlasAuthManager } from '../../src/plugins/service-mongo-atlas/utils/At import { AtlasCredentialCache } from '../../src/plugins/service-mongo-atlas/utils/AtlasCredentialCache'; import { AtlasHttpClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasHttpClient'; -// We'll monkey-patch modules rather than using jest.mock const globalAny: any = global; type FetchFn = (url: string, init?: any) => Promise; From 6d0be4e9de6958a59026ede1c079ba00ffced46f Mon Sep 17 00:00:00 2001 From: Xing Fan Date: Sun, 14 Sep 2025 12:36:32 +0000 Subject: [PATCH 08/10] update tests --- jest.config.js | 1 + .../AtlasAdministrationClient.test.ts | 46 +++++++---------- .../__tests__}/AtlasAuthManager.test.ts | 51 ++++++++----------- .../__tests__}/AtlasCredentialCache.test.ts | 43 +++++++--------- .../__tests__}/AtlasHttpClient.test.ts | 45 ++++++++-------- .../__tests__/jest.setup.atlas.ts | 21 ++++++++ 6 files changed, 101 insertions(+), 106 deletions(-) rename {test/AtlasService => src/plugins/service-mongo-atlas/__tests__}/AtlasAdministrationClient.test.ts (71%) rename {test/AtlasService => src/plugins/service-mongo-atlas/__tests__}/AtlasAuthManager.test.ts (67%) rename {test/AtlasService => src/plugins/service-mongo-atlas/__tests__}/AtlasCredentialCache.test.ts (55%) rename {test/AtlasService => src/plugins/service-mongo-atlas/__tests__}/AtlasHttpClient.test.ts (59%) create mode 100644 src/plugins/service-mongo-atlas/__tests__/jest.setup.atlas.ts diff --git a/jest.config.js b/jest.config.js index a4de3e960..5ac58f70d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,7 @@ module.exports = { testEnvironment: 'node', testMatch: ['/src/**/*.test.ts'], + setupFilesAfterEnv: ['/src/plugins/service-mongo-atlas/__tests__/jest.setup.atlas.ts'], transform: { '^.+.tsx?$': ['ts-jest', {}], }, diff --git a/test/AtlasService/AtlasAdministrationClient.test.ts b/src/plugins/service-mongo-atlas/__tests__/AtlasAdministrationClient.test.ts similarity index 71% rename from test/AtlasService/AtlasAdministrationClient.test.ts rename to src/plugins/service-mongo-atlas/__tests__/AtlasAdministrationClient.test.ts index 6c14a7d78..d98fd6f45 100644 --- a/test/AtlasService/AtlasAdministrationClient.test.ts +++ b/src/plugins/service-mongo-atlas/__tests__/AtlasAdministrationClient.test.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import assert from 'assert'; -import { type AtlasApiResponse } from '../../src/plugins/service-mongo-atlas/utils/AtlasAdminApiTypes'; -import { AtlasAdministrationClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient'; -import { AtlasHttpClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasHttpClient'; +import { type AtlasApiResponse } from '../types/AtlasAdminApiTypes'; +import { AtlasAdministrationClient } from '../utils/AtlasAdministrationClient'; +import { AtlasHttpClient } from '../utils/AtlasHttpClient'; function mockJson(data: T): Response { return { ok: true, status: 200, json: async () => data } as any as Response; @@ -16,7 +15,7 @@ function mockFail(status: number, text: string): Response { return { ok: false, status, text: async () => text } as any as Response; } -suite('AtlasAdministrationClient', () => { +describe('AtlasAdministrationClient (Jest)', () => { const orgId = 'org'; const projectId = 'proj'; @@ -24,20 +23,16 @@ suite('AtlasAdministrationClient', () => { let originalPost: typeof AtlasHttpClient.post; let originalDelete: typeof AtlasHttpClient.delete; - function resetStubs() { - AtlasHttpClient.get = originalGet; - AtlasHttpClient.post = originalPost; - AtlasHttpClient.delete = originalDelete; - } - - setup(() => { + beforeEach(() => { originalGet = AtlasHttpClient.get.bind(AtlasHttpClient); originalPost = AtlasHttpClient.post.bind(AtlasHttpClient); originalDelete = AtlasHttpClient.delete.bind(AtlasHttpClient); }); - teardown(() => { - resetStubs(); + afterEach(() => { + AtlasHttpClient.get = originalGet; + AtlasHttpClient.post = originalPost; + AtlasHttpClient.delete = originalDelete; }); test('listProjects success builds query params', async () => { @@ -55,13 +50,13 @@ suite('AtlasAdministrationClient', () => { itemsPerPage: 5, includeCount: true, }); - assert.strictEqual(resp.totalCount, 1); - assert.ok(/pageNum=1/.test(calledEndpoint), 'Expected pageNum query param'); + expect(resp.totalCount).toBe(1); + expect(/pageNum=1/.test(calledEndpoint)).toBe(true); }); test('listProjects failure throws', async () => { AtlasHttpClient.get = (async () => mockFail(500, 'err')) as any; - await assert.rejects(() => AtlasAdministrationClient.listProjects(orgId), /Failed to list Atlas projects/); + await expect(AtlasAdministrationClient.listProjects(orgId)).rejects.toThrow(/Failed to list Atlas projects/); }); test('listClusters success', async () => { @@ -77,45 +72,40 @@ suite('AtlasAdministrationClient', () => { }; AtlasHttpClient.get = (async () => mockJson(data)) as any; const resp = await AtlasAdministrationClient.listClusters(orgId, projectId); - assert.strictEqual(resp.results.length, 1); + expect(resp.results.length).toBe(1); }); test('getCluster failure throws', async () => { AtlasHttpClient.get = (async () => mockFail(404, 'missing')) as any; - await assert.rejects( - () => AtlasAdministrationClient.getCluster(orgId, projectId, 'cl'), + await expect(AtlasAdministrationClient.getCluster(orgId, projectId, 'cl')).rejects.toThrow( /Failed to get cluster/, ); }); test('listDatabaseUsers failure throws', async () => { AtlasHttpClient.get = (async () => mockFail(400, 'bad')) as any; - await assert.rejects( - () => AtlasAdministrationClient.listDatabaseUsers(orgId, projectId), + await expect(AtlasAdministrationClient.listDatabaseUsers(orgId, projectId)).rejects.toThrow( /Failed to list database users/, ); }); test('getAccessList failure throws', async () => { AtlasHttpClient.get = (async () => mockFail(401, 'unauth')) as any; - await assert.rejects( - () => AtlasAdministrationClient.getAccessList(orgId, projectId), + await expect(AtlasAdministrationClient.getAccessList(orgId, projectId)).rejects.toThrow( /Failed to get access list/, ); }); test('createAccessListEntries failure throws', async () => { AtlasHttpClient.post = (async () => mockFail(500, 'boom')) as any; - await assert.rejects( - () => AtlasAdministrationClient.createAccessListEntries(orgId, projectId, []), + await expect(AtlasAdministrationClient.createAccessListEntries(orgId, projectId, [])).rejects.toThrow( /Failed to create access list entries/, ); }); test('deleteAccessListEntry failure throws', async () => { AtlasHttpClient.delete = (async () => mockFail(403, 'deny')) as any; - await assert.rejects( - () => AtlasAdministrationClient.deleteAccessListEntry(orgId, projectId, '1.1.1.1'), + await expect(AtlasAdministrationClient.deleteAccessListEntry(orgId, projectId, '1.1.1.1')).rejects.toThrow( /Failed to delete access list entry/, ); }); diff --git a/test/AtlasService/AtlasAuthManager.test.ts b/src/plugins/service-mongo-atlas/__tests__/AtlasAuthManager.test.ts similarity index 67% rename from test/AtlasService/AtlasAuthManager.test.ts rename to src/plugins/service-mongo-atlas/__tests__/AtlasAuthManager.test.ts index c355a3381..06866e968 100644 --- a/test/AtlasService/AtlasAuthManager.test.ts +++ b/src/plugins/service-mongo-atlas/__tests__/AtlasAuthManager.test.ts @@ -3,38 +3,37 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import assert from 'assert'; -import { AtlasAuthManager } from '../../src/plugins/service-mongo-atlas/utils/AtlasAuthManager'; -import { AtlasCredentialCache } from '../../src/plugins/service-mongo-atlas/utils/AtlasCredentialCache'; - -// Mock global fetch -const globalAny: any = global; +import { AtlasAuthManager } from '../utils/AtlasAuthManager'; +import { AtlasCredentialCache } from '../utils/AtlasCredentialCache'; type FetchFn = (url: string, init?: any) => Promise; -suite('AtlasAuthManager', () => { +describe('AtlasAuthManager (Jest)', () => { const orgId = 'authOrg'; const clientId = 'client'; const clientSecret = 'secret'; - let originalFetch: unknown; + let originalFetch: any; - setup(() => { - originalFetch = globalAny.fetch; - delete globalAny.fetch; + beforeEach(() => { + originalFetch = global.fetch; + // ensure we start with a clean slate + // @ts-expect-error override for test + delete global.fetch; }); - teardown(() => { + afterEach(() => { AtlasCredentialCache.clearAtlasCredentials(orgId); if (originalFetch) { - globalAny.fetch = originalFetch; + global.fetch = originalFetch; } else { - delete globalAny.fetch; + // @ts-expect-error restore + delete global.fetch; } }); test('getOAuthBasicAuthHeader encodes credentials', () => { const hdr = AtlasAuthManager.getOAuthBasicAuthHeader('id', 'sec'); - assert.strictEqual(hdr, 'Basic aWQ6c2Vj'); + expect(hdr).toBe('Basic aWQ6c2Vj'); }); test('requestOAuthToken success stores nothing automatically', async () => { @@ -43,45 +42,39 @@ suite('AtlasAuthManager', () => { status: 200, json: async () => ({ access_token: 'tok', expires_in: 100, token_type: 'Bearer' }), }); - globalAny.fetch = mockFetch; + global.fetch = mockFetch as any; const resp = await AtlasAuthManager.requestOAuthToken(clientId, clientSecret); - assert.strictEqual(resp.access_token, 'tok'); + expect(resp.access_token).toBe('tok'); }); test('requestOAuthToken failure throws with status and text', async () => { const mockFetch: FetchFn = async () => ({ ok: false, status: 400, text: async () => 'bad request' }); - globalAny.fetch = mockFetch; - await assert.rejects( - () => AtlasAuthManager.requestOAuthToken(clientId, clientSecret), - /400/, - 'Should include status code 400', - ); + global.fetch = mockFetch as any; + await expect(AtlasAuthManager.requestOAuthToken(clientId, clientSecret)).rejects.toThrow(/400/); }); test('getAuthorizationHeader returns bearer token using cache', async () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, clientId, clientSecret); - // add cached token AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'cachedToken', 3600); const hdr = await AtlasAuthManager.getAuthorizationHeader(orgId); - assert.strictEqual(hdr, 'Bearer cachedToken'); + expect(hdr).toBe('Bearer cachedToken'); }); test('getAuthorizationHeader fetches new token when expired', async () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, clientId, clientSecret); - // expired token AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'old', -1); const mockFetch: FetchFn = async () => ({ ok: true, status: 200, json: async () => ({ access_token: 'newToken', expires_in: 50, token_type: 'Bearer' }), }); - globalAny.fetch = mockFetch; + global.fetch = mockFetch as any; const hdr = await AtlasAuthManager.getAuthorizationHeader(orgId); - assert.strictEqual(hdr, 'Bearer newToken'); + expect(hdr).toBe('Bearer newToken'); }); test('getAuthorizationHeader undefined when no credentials', async () => { const hdr = await AtlasAuthManager.getAuthorizationHeader('missing'); - assert.strictEqual(hdr, undefined); + expect(hdr).toBeUndefined(); }); }); diff --git a/test/AtlasService/AtlasCredentialCache.test.ts b/src/plugins/service-mongo-atlas/__tests__/AtlasCredentialCache.test.ts similarity index 55% rename from test/AtlasService/AtlasCredentialCache.test.ts rename to src/plugins/service-mongo-atlas/__tests__/AtlasCredentialCache.test.ts index 9e4ae3c09..2c93eb7e3 100644 --- a/test/AtlasService/AtlasCredentialCache.test.ts +++ b/src/plugins/service-mongo-atlas/__tests__/AtlasCredentialCache.test.ts @@ -3,62 +3,55 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import assert from 'assert'; -import { AtlasCredentialCache } from '../../src/plugins/service-mongo-atlas/utils/AtlasCredentialCache'; +import { AtlasCredentialCache } from '../utils/AtlasCredentialCache'; -// Helper to access private store for cleanup without exposing implementation -function clear(orgId: string) { - AtlasCredentialCache.clearAtlasCredentials(orgId); -} - -suite('AtlasCredentialCache', () => { +describe('AtlasCredentialCache (Jest)', () => { const orgId = 'OrgOne'; const orgIdDifferentCase = 'orgone'; - teardown(() => { - clear(orgId); + afterEach(() => { + AtlasCredentialCache.clearAtlasCredentials(orgId); }); test('set and get OAuth credentials (case insensitive key)', () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'clientId', 'clientSecret'); const creds = AtlasCredentialCache.getAtlasCredentials(orgIdDifferentCase); - assert.ok(creds, 'creds should be defined'); - assert.strictEqual(creds?.authType, 'oauth'); - assert.strictEqual(creds?.oauth?.clientId, 'clientId'); + expect(creds).toBeDefined(); + expect(creds?.authType).toBe('oauth'); + expect(creds?.oauth?.clientId).toBe('clientId'); }); test('set and get Digest credentials', () => { AtlasCredentialCache.setAtlasDigestCredentials(orgId, 'public', 'private'); const creds = AtlasCredentialCache.getAtlasCredentials(orgId); - assert.strictEqual(creds?.authType, 'digest'); - assert.strictEqual(creds?.digest?.publicKey, 'public'); + expect(creds?.authType).toBe('digest'); + expect(creds?.digest?.publicKey).toBe('public'); }); test('update token caches expiry and value', () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'client', 'secret'); - // use a larger expiry to pass buffer check ( >60s ) AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'token123', 120); const creds = AtlasCredentialCache.getAtlasCredentials(orgId)!; - assert.strictEqual(creds.oauth?.accessToken, 'token123'); - assert.ok((creds.oauth?.tokenExpiry ?? 0) > Date.now()); - assert.ok(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)); + expect(creds.oauth?.accessToken).toBe('token123'); + expect((creds.oauth?.tokenExpiry ?? 0) > Date.now()).toBe(true); + expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(true); }); test('token validity false when missing token or expired', () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'client', 'secret'); - assert.ok(!AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)); - AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'token123', -1); // expired - assert.ok(!AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)); + expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(false); + AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'token123', -1); + expect(AtlasCredentialCache.isAtlasOAuthTokenValid(orgId)).toBe(false); }); test('clear credentials removes entry', () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'client', 'secret'); - assert.ok(AtlasCredentialCache.getAtlasCredentials(orgId)); + expect(AtlasCredentialCache.getAtlasCredentials(orgId)).toBeDefined(); AtlasCredentialCache.clearAtlasCredentials(orgId); - assert.strictEqual(AtlasCredentialCache.getAtlasCredentials(orgId), undefined); + expect(AtlasCredentialCache.getAtlasCredentials(orgId)).toBeUndefined(); }); test('updateAtlasOAuthToken throws if oauth creds missing', () => { - assert.throws(() => AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'tkn'), /No Atlas OAuth credentials/); + expect(() => AtlasCredentialCache.updateAtlasOAuthToken(orgId, 'tkn')).toThrow(/No Atlas OAuth credentials/); }); }); diff --git a/test/AtlasService/AtlasHttpClient.test.ts b/src/plugins/service-mongo-atlas/__tests__/AtlasHttpClient.test.ts similarity index 59% rename from test/AtlasService/AtlasHttpClient.test.ts rename to src/plugins/service-mongo-atlas/__tests__/AtlasHttpClient.test.ts index f21b5acdb..e9b4a5230 100644 --- a/test/AtlasService/AtlasHttpClient.test.ts +++ b/src/plugins/service-mongo-atlas/__tests__/AtlasHttpClient.test.ts @@ -3,40 +3,37 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import assert from 'assert'; -import { AtlasAuthManager } from '../../src/plugins/service-mongo-atlas/utils/AtlasAuthManager'; -import { AtlasCredentialCache } from '../../src/plugins/service-mongo-atlas/utils/AtlasCredentialCache'; -import { AtlasHttpClient } from '../../src/plugins/service-mongo-atlas/utils/AtlasHttpClient'; - -const globalAny: any = global; +import { AtlasAuthManager } from '../utils/AtlasAuthManager'; +import { AtlasCredentialCache } from '../utils/AtlasCredentialCache'; +import { AtlasHttpClient } from '../utils/AtlasHttpClient'; type FetchFn = (url: string, init?: any) => Promise; -suite('AtlasHttpClient', () => { +describe('AtlasHttpClient (Jest)', () => { const orgId = 'org-http'; - let originalFetch: unknown; + let originalFetch: any; let originalGetAuthHeader: typeof AtlasAuthManager.getAuthorizationHeader; - setup(() => { - originalFetch = globalAny.fetch; - originalGetAuthHeader = AtlasAuthManager.getAuthorizationHeader.bind( - AtlasAuthManager, - ) as unknown as typeof AtlasAuthManager.getAuthorizationHeader; - delete globalAny.fetch; + beforeEach(() => { + originalFetch = global.fetch; + originalGetAuthHeader = AtlasAuthManager.getAuthorizationHeader.bind(AtlasAuthManager); + // @ts-expect-error override + delete global.fetch; AtlasCredentialCache.clearAtlasCredentials(orgId); }); - teardown(() => { + afterEach(() => { if (originalFetch) { - globalAny.fetch = originalFetch; + global.fetch = originalFetch; } else { - delete globalAny.fetch; + // @ts-expect-error restore + delete global.fetch; } AtlasAuthManager.getAuthorizationHeader = originalGetAuthHeader as any; }); test('throws when no credentials', async () => { - await assert.rejects(() => AtlasHttpClient.get(orgId, '/groups'), /No Atlas credentials/); + await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(/No Atlas credentials/); }); test('uses OAuth flow and sets Authorization header', async () => { @@ -51,23 +48,23 @@ suite('AtlasHttpClient', () => { fetchSpyCalls.push([_url, init]); return { ok: true, status: 200, json: async () => ({ ok: true }) } as any; }; - globalAny.fetch = fetchSpy; + global.fetch = fetchSpy as any; await AtlasHttpClient.get(orgId, '/groups'); - assert.ok(called, 'Expected getAuthorizationHeader to be called'); + expect(called).toBe(true); const headers = fetchSpyCalls[0][1].headers; - assert.strictEqual(headers.Authorization, 'Bearer tokenX'); + expect(headers.Authorization).toBe('Bearer tokenX'); }); test('oauth flow throws when missing bearer', async () => { AtlasCredentialCache.setAtlasOAuthCredentials(orgId, 'cid', 'sec'); AtlasAuthManager.getAuthorizationHeader = (async () => 'Invalid') as any; - await assert.rejects(() => AtlasHttpClient.get(orgId, '/groups'), /Failed to obtain valid OAuth token/); + await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(/Failed to obtain valid OAuth token/); }); test('digest flow uses digest-fetch client and throws on non-ok', async () => { AtlasCredentialCache.setAtlasDigestCredentials(orgId, 'pub', 'priv'); - globalAny.fetch = async () => ({ ok: false, status: 401, text: async () => 'Unauthorized' }); - await assert.rejects(() => AtlasHttpClient.get(orgId, '/groups')); + global.fetch = (async () => ({ ok: false, status: 401, text: async () => 'Unauthorized' })) as any; + await expect(AtlasHttpClient.get(orgId, '/groups')).rejects.toThrow(); }); }); diff --git a/src/plugins/service-mongo-atlas/__tests__/jest.setup.atlas.ts b/src/plugins/service-mongo-atlas/__tests__/jest.setup.atlas.ts new file mode 100644 index 000000000..82ecffa5a --- /dev/null +++ b/src/plugins/service-mongo-atlas/__tests__/jest.setup.atlas.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** Global Jest setup for Atlas service tests */ + +// Mock digest-fetch (ESM) with a simple constructor returning an object containing fetch. +// Allows tests to override behavior by redefining the returned fetch if needed. +jest.mock('digest-fetch', () => { + return function MockDigestClient() { + return { + fetch: (_url: string, _init?: Record) => + ({ + ok: false, + status: 401, + text: () => Promise.resolve('Unauthorized'), + }) as Response, + }; + }; +}); From 47327973c467a019f2fd01a896c59f72e6eb3952 Mon Sep 17 00:00:00 2001 From: Xing Fan Date: Sun, 14 Sep 2025 12:52:45 +0000 Subject: [PATCH 09/10] remove dup file --- .../AtlasAdministrationClient.test.ts | 2 +- .../types/AtlasAdminApiTypes.ts | 213 ------------------ .../utils/AtlasAdministrationClient.ts | 2 +- 3 files changed, 2 insertions(+), 215 deletions(-) delete mode 100644 src/plugins/service-mongo-atlas/types/AtlasAdminApiTypes.ts diff --git a/src/plugins/service-mongo-atlas/__tests__/AtlasAdministrationClient.test.ts b/src/plugins/service-mongo-atlas/__tests__/AtlasAdministrationClient.test.ts index d98fd6f45..6ece8f3db 100644 --- a/src/plugins/service-mongo-atlas/__tests__/AtlasAdministrationClient.test.ts +++ b/src/plugins/service-mongo-atlas/__tests__/AtlasAdministrationClient.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type AtlasApiResponse } from '../types/AtlasAdminApiTypes'; +import { type AtlasApiResponse } from '../utils/AtlasAdminApiTypes'; import { AtlasAdministrationClient } from '../utils/AtlasAdministrationClient'; import { AtlasHttpClient } from '../utils/AtlasHttpClient'; diff --git a/src/plugins/service-mongo-atlas/types/AtlasAdminApiTypes.ts b/src/plugins/service-mongo-atlas/types/AtlasAdminApiTypes.ts deleted file mode 100644 index 08e7fb0f3..000000000 --- a/src/plugins/service-mongo-atlas/types/AtlasAdminApiTypes.ts +++ /dev/null @@ -1,213 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// Centralized type contracts for MongoDB Atlas Administration API responses. -// These were originally defined in AtlasAdministrationClient.ts and moved out for reuse and clarity. - -export interface AtlasProject { - readonly id?: string; - readonly name: string; - readonly orgId: string; - readonly created: string; - readonly clusterCount: number; - readonly links?: Array<{ - readonly href: string; - readonly rel: string; - }>; - readonly regionUsageRestrictions?: 'COMMERCIAL_FEDRAMP_REGIONS_ONLY' | 'GOV_REGIONS_ONLY'; - readonly tags?: Array<{ - readonly key: string; - readonly value: string; - }>; - readonly withDefaultAlertsSettings?: boolean; -} - -export interface AtlasCluster { - readonly id?: string; - readonly name?: string; - readonly groupId?: string; - readonly mongoDBMajorVersion?: string; - readonly mongoDBVersion?: string; - readonly clusterType: 'REPLICASET' | 'SHARDED' | 'GEOSHARDED'; - readonly providerSettings: { - readonly providerName: string; - readonly regionName: string; - readonly instanceSizeName: string; - }; - readonly connectionStrings?: { - readonly awsPrivateLink?: object; - readonly awsPrivateLinkSrv?: object; - readonly standard?: string; - readonly standardSrv?: string; - readonly private?: string; - readonly privateEndpoint?: Array<{ - readonly connectionString?: string; - readonly endpoints?: Array<{ - readonly endpointId?: string; - readonly providerName?: 'AWS' | 'AZURE' | 'GCP'; - readonly region?: string; - }>; - readonly srvConnectionString?: string; - readonly srvShardOptimizedConnectionString?: string; - readonly type?: 'MONGOD' | 'MONGOS'; - }>; - readonly privateSrv?: string; - }; - readonly stateName: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'DELETED' | 'REPAIRING'; - readonly createDate?: string; // DATE-TIME - readonly links?: Array<{ - readonly href: string; - readonly rel: string; - }>; - readonly acceptDataRisksAndForceReplicaSetReconfig?: string; // DATE-TIME - readonly advancedConfiguration?: { - readonly customOpensslCipherConfigTls12?: Array; - readonly minimumEnabledTlsProtocol?: 'TLS1_0' | 'TLS1_1' | 'TLS1_2'; - readonly tlsCipherConfigMode?: 'CUSTOM' | 'DEFAULT'; - }; - readonly backupEnabled?: boolean; - readonly biConnector?: { - readonly enabled?: boolean; - readonly readPreference?: 'PRIMARY' | 'SECONDARY' | 'ANALYTICS'; - }; - readonly configServerManagementMode?: 'ATLAS_MANAGED' | 'FIXED_TO_DEDICATED'; - readonly configServerType?: 'DEDICATED' | 'EMBEDDED'; - readonly diskWarmingMode?: 'FULLY_WARMED' | 'VISIBLE_EARLIER'; - readonly encryptionAtRestProvider?: 'AWS' | 'AZURE' | 'GCP' | 'NONE'; - readonly featureCompatibilityVersion?: string; - readonly featureCompatibilityVersionExpirationDate?: string; // DATE-TIME - readonly globalClusterSelfManagedSharding?: boolean; - readonly mongoDBEmployeeAccessGrant?: { - readonly expirationTime: string; // DATE-TIME - readonly grantType: - | 'CLUSTER_DATABASE_LOGS' - | 'CLUSTER_INFRASTRUCTURE' - | 'CLUSTER_INFRASTRUCTURE_AND_APP_SERVICES_SYNC_DATA'; - readonly links?: Array<{ - readonly href: string; - readonly rel: string; - }>; - }; - readonly paused?: boolean; - readonly pitEnabled?: boolean; - readonly redactClientLogData?: boolean; - readonly replicaSetScalingStrategy?: 'SEQUENTIAL' | 'WORKLOAD_TYPE' | 'NODE_TYPE'; - readonly replicationSpecs?: Array<{ - readonly id?: string; - readonly regionConfigs?: Array<{ - readonly electableSpecs?: { - readonly diskSizeGB?: number; // DOUBLE - readonly diskIOPS?: number; // INTEGER - readonly ebsVolumeType?: 'STANDARD' | 'PROVISIONED'; - readonly instanceSize?: - | 'M10' - | 'M20' - | 'M30' - | 'M40' - | 'M50' - | 'M60' - | 'M80' - | 'M100' - | 'M140' - | 'M200' - | 'M300' - | 'R40' - | 'R50' - | 'R60' - | 'R80' - | 'R200' - | 'R300' - | 'R400' - | 'R700' - | 'M40_NVME' - | 'M50_NVME' - | 'M60_NVME' - | 'M80_NVME' - | 'M200_NVME' - | 'M400_NVME'; - readonly nodeCount?: number; // INTEGER - }; - readonly priority?: number; // 0-7 - readonly providerName?: 'AWS' | 'AZURE' | 'GCP' | 'TENANT'; - readonly regionName?: string; - readonly analyticsAutoScaling?: { - readonly compute?: { - readonly enabled: boolean; - readonly maxInstanceSize?: string; - readonly minInstanceSize?: string; - readonly predictiveEnabled?: boolean; - readonly scaleDownEnabled?: boolean; - }; - readonly diskGB?: { readonly enabled?: boolean }; - }; - readonly analyticsSpecs?: object; - readonly autoScaling?: { - readonly compute?: { - readonly enabled: boolean; - readonly maxInstanceSize?: string; - readonly minInstanceSize?: string; - readonly predictiveEnabled?: boolean; - readonly scaleDownEnabled?: boolean; - }; - readonly diskGB?: { readonly enabled?: boolean }; - }; - readonly readOnlySpecs?: object; - readonly zoneId?: string; - readonly zoneName?: string; - }>; - readonly rootCertType?: 'ISRGROOTX1'; - readonly stateName?: 'IDLE' | 'CREATING' | 'UPDATING' | 'DELETING' | 'REPAIRING'; - readonly tags?: Array<{ readonly key: string; readonly value: string }>; - readonly terminationProtectionEnabled?: boolean; - readonly versionReleaseSystem?: 'LTS' | 'CONTINUOUS'; - }>; -} - -export interface AtlasDatabaseUser { - readonly username: string; // Max 1024 - readonly databaseName: 'admin' | '$external'; - readonly groupId: string; - readonly roles: Array<{ - readonly roleName: string; - readonly databaseName: string; - readonly collectionName?: string; - }>; - readonly scopes?: Array<{ - readonly name: string; - readonly type: 'CLUSTER' | 'DATA_LAKE' | 'STREAM'; - }>; - readonly labels?: Array<{ readonly key: string; readonly value: string }>; - readonly ldapAuthType?: 'NONE' | 'USER' | 'GROUP'; - readonly x509Type?: 'NONE' | 'MANAGED' | 'CUSTOMER'; - readonly awsIAMType?: 'NONE' | 'USER' | 'ROLE'; - readonly links?: Array<{ readonly href: string; readonly rel: string }>; - readonly deleteAfterDate?: string; // DATE-TIME - readonly description?: string; // Max 100 - readonly oidcAuthType?: 'NONE' | 'USER' | 'IDP_GROUP'; -} - -export interface AtlasAccessListEntry { - readonly groupId: string; - readonly ipAddress?: string; - readonly cidrBlock?: string; - readonly awsSecurityGroup?: string; - readonly comment?: string; - readonly deleteAfterDate?: string; - readonly links?: Array<{ readonly href: string; readonly rel: string }>; -} - -export interface AtlasApiResponse { - readonly results: T[]; - readonly totalCount: number; - readonly links?: Array<{ readonly href: string; readonly rel: string }>; -} - -export interface CreateAccessListEntryParams { - readonly ipAddress?: string; - readonly cidrBlock?: string; - readonly awsSecurityGroup?: string; - readonly comment?: string; - readonly deleteAfterDate?: string; -} diff --git a/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts index b7a5222a4..96e5847e5 100644 --- a/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts +++ b/src/plugins/service-mongo-atlas/utils/AtlasAdministrationClient.ts @@ -11,7 +11,7 @@ import { type AtlasDatabaseUser, type AtlasProject, type CreateAccessListEntryParams, -} from '../types/AtlasAdminApiTypes'; +} from '../utils/AtlasAdminApiTypes'; import { AtlasHttpClient } from './AtlasHttpClient'; /** From 39f5f8e946d4b4cd25a49f9e0a71b7fb21776862 Mon Sep 17 00:00:00 2001 From: Xing Fan Date: Sun, 14 Sep 2025 13:29:36 +0000 Subject: [PATCH 10/10] more --- l10n/bundle.l10n.json | 2 ++ .../service-mongo-atlas/utils/AtlasCredentialCache.ts | 9 ++++++--- src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts | 4 +++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index db0ca45cc..7b5ed7593 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -303,6 +303,7 @@ "New Local Connection…": "New Local Connection…", "No": "No", "No Atlas credentials found for organization {0}": "No Atlas credentials found for organization {0}", + "No Atlas OAuth credentials found for organization {0}": "No Atlas OAuth credentials found for organization {0}", "No authentication method selected.": "No authentication method selected.", "No authentication methods available for \"{cluster}\".": "No authentication methods available for \"{cluster}\".", "No Azure subscription found for this tree item.": "No Azure subscription found for this tree item.", @@ -354,6 +355,7 @@ "Remind Me Later": "Remind Me Later", "Rename Connection": "Rename Connection", "Report an issue": "Report an issue", + "Request failed with status {status}: {errorText}": "Request failed with status {status}: {errorText}", "Resource group \"{0}\" already exists in subscription \"{1}\".": "Resource group \"{0}\" already exists in subscription \"{1}\".", "Reusing active connection for \"{cluster}\".": "Reusing active connection for \"{cluster}\".", "Revisit connection details and try again.": "Revisit connection details and try again.", diff --git a/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts b/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts index 27b4033ac..bf06cc6e6 100644 --- a/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts +++ b/src/plugins/service-mongo-atlas/utils/AtlasCredentialCache.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as l10n from '@vscode/l10n'; import { CaseInsensitiveMap } from '../../../utils/CaseInsensitiveMap'; export interface AtlasCredentials { @@ -82,15 +83,17 @@ export class AtlasCredentialCache { * * @param orgId - The organization id for the Atlas credential instance * @param accessToken - The access token received from OAuth - * @param expiresInSeconds - Token lifetime in seconds + * @param expiresInSeconds - Token lifetime in seconds, maximum value is 3600 */ public static updateAtlasOAuthToken(orgId: string, accessToken: string, expiresInSeconds: number = 3600): void { const credentials = AtlasCredentialCache._store.get(orgId); if (!credentials?.oauth) { - throw new Error(`No Atlas OAuth credentials found for organization ${orgId}`); + throw new Error(l10n.t('No Atlas OAuth credentials found for organization {0}', orgId)); } - const tokenExpiry = Date.now() + expiresInSeconds * 1000; + // expiresInSeconds should never exceeds 3600 as the lifecycle of token for Atlas administration API is + // 1 hour(3600 seconds): https://www.mongodb.com/docs/atlas/configure-api-access/#request-an-access-token. + const tokenExpiry = Date.now() + (expiresInSeconds > 3600 ? 3600 : expiresInSeconds) * 1000; credentials.oauth.accessToken = accessToken; credentials.oauth.tokenExpiry = tokenExpiry; diff --git a/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts b/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts index 6bb21dc6e..c5b266dc2 100644 --- a/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts +++ b/src/plugins/service-mongo-atlas/utils/AtlasHttpClient.ts @@ -142,7 +142,9 @@ export class AtlasHttpClient { if (!response.ok) { const errorText: string = await response.text(); - throw new Error(`Request failed with status ${response.status}: ${errorText}`); + throw new Error( + l10n.t('Request failed with status {status}: {errorText}', { status: response.status, errorText }), + ); } return response;