Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .azure-pipelines/compliance/CredScanSuppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@
{
"file": "src\\documentdb\\utils\\DocumentDBConnectionString.test.ts",
"_justification": "Fake credentials used for unit tests."
},
{
"file": "src\\services\\connectionStorageService.cleanup.test.ts",
"_justification": "Fake credentials used for unit tests."
},
{
"file": "src\\services\\connectionStorageService.contract.test.ts",
"_justification": "Fake credentials used for unit tests."
},
{
"file": "src\\services\\connectionStorageService.test.ts",
"_justification": "Fake credentials used for unit tests."
}
]
}
233 changes: 233 additions & 0 deletions src/documentdb/utils/DocumentDBConnectionString.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,4 +546,237 @@ describe('DocumentDBConnectionString', () => {
expect(connStr.searchParams.get('tag3')).toBe('test#1');
});
});

describe('deduplicateQueryParameters', () => {
it('should remove exact duplicate key=value pairs', () => {
const uri = 'mongodb://host.example.com:27017/?ssl=true&ssl=true&appName=app';

const connStr = new DocumentDBConnectionString(uri);
const deduplicated = connStr.deduplicateQueryParameters();

expect(deduplicated).toBe('mongodb://host.example.com:27017/?ssl=true&appName=app');
});

it('should preserve different values for the same key', () => {
// Some MongoDB parameters legitimately allow multiple values
const uri = 'mongodb://host.example.com:27017/?readPreferenceTags=dc:east&readPreferenceTags=dc:west';

const connStr = new DocumentDBConnectionString(uri);
const deduplicated = connStr.deduplicateQueryParameters();

// Both values should be preserved since they are different
expect(deduplicated).toContain('readPreferenceTags=dc%3Aeast');
expect(deduplicated).toContain('readPreferenceTags=dc%3Awest');
});

it('should handle connection string without query parameters', () => {
const uri = 'mongodb://host.example.com:27017/database';

const connStr = new DocumentDBConnectionString(uri);
const deduplicated = connStr.deduplicateQueryParameters();

expect(deduplicated).toBe('mongodb://host.example.com:27017/database');
});

it('should handle multiple duplicates of the same parameter', () => {
const uri = 'mongodb://host.example.com:27017/?ssl=true&ssl=true&ssl=true&appName=app&appName=app';

const connStr = new DocumentDBConnectionString(uri);
const deduplicated = connStr.deduplicateQueryParameters();

expect(deduplicated).toBe('mongodb://host.example.com:27017/?ssl=true&appName=app');
});

it('should preserve special characters in values when deduplicating', () => {
const uri = 'mongodb://host.example.com:27017/?appName=@user@&appName=@user@&ssl=true';

const connStr = new DocumentDBConnectionString(uri);
const deduplicated = connStr.deduplicateQueryParameters();

// Should have only one appName with encoded @ characters
expect(deduplicated).toBe('mongodb://host.example.com:27017/?appName=%40user%40&ssl=true');
});

it('should work correctly after multiple parse/serialize cycles', () => {
const original = 'mongodb://host.example.com:27017/?ssl=true&appName=@user@';

// First cycle
const parsed1 = new DocumentDBConnectionString(original);
const str1 = parsed1.deduplicateQueryParameters();

// Second cycle
const parsed2 = new DocumentDBConnectionString(str1);
const str2 = parsed2.deduplicateQueryParameters();

// Third cycle
const parsed3 = new DocumentDBConnectionString(str2);
const str3 = parsed3.deduplicateQueryParameters();

// All should be identical - no parameter doubling
expect(str1).toBe(str2);
expect(str2).toBe(str3);

// Verify the values are still correct
expect(parsed3.searchParams.get('ssl')).toBe('true');
expect(parsed3.searchParams.get('appName')).toBe('@user@');
});
});

describe('hasDuplicateParameters', () => {
it('should return true when there are duplicate parameters', () => {
const uri = 'mongodb://host.example.com:27017/?ssl=true&ssl=true';

const connStr = new DocumentDBConnectionString(uri);

expect(connStr.hasDuplicateParameters()).toBe(true);
});

it('should return false when there are no duplicate parameters', () => {
const uri = 'mongodb://host.example.com:27017/?ssl=true&appName=app';

const connStr = new DocumentDBConnectionString(uri);

expect(connStr.hasDuplicateParameters()).toBe(false);
});

it('should return false when same key has different values', () => {
const uri = 'mongodb://host.example.com:27017/?tag=prod&tag=dev';

const connStr = new DocumentDBConnectionString(uri);

// Different values for same key is not considered a duplicate
expect(connStr.hasDuplicateParameters()).toBe(false);
});

it('should return false for connection string without query parameters', () => {
const uri = 'mongodb://host.example.com:27017/database';

const connStr = new DocumentDBConnectionString(uri);

expect(connStr.hasDuplicateParameters()).toBe(false);
});
});

describe('normalize static method', () => {
it('should normalize a connection string with duplicates', () => {
const uri = 'mongodb://host.example.com:27017/?ssl=true&ssl=true&appName=app';

const normalized = DocumentDBConnectionString.normalize(uri);

expect(normalized).toBe('mongodb://host.example.com:27017/?ssl=true&appName=app');
});

it('should return original string if parsing fails', () => {
const invalidUri = 'not-a-valid-connection-string';

const normalized = DocumentDBConnectionString.normalize(invalidUri);

expect(normalized).toBe(invalidUri);
});

it('should return empty string for empty input', () => {
expect(DocumentDBConnectionString.normalize('')).toBe('');
});

it('should handle credentials correctly during normalization', () => {
const uri = 'mongodb://user:pass@host.example.com:27017/?ssl=true&ssl=true';

const normalized = DocumentDBConnectionString.normalize(uri);

// Should preserve credentials and remove duplicates
expect(normalized).toContain('user');
expect(normalized).toContain('pass');
expect(normalized).not.toMatch(/ssl=true.*ssl=true/);
});
});

describe('real-world Cosmos DB RU connection string with appName containing @', () => {
// This is the exact format used by Azure Cosmos DB for MongoDB RU connections
const cosmosRUConnectionString =
'mongodb://auername:weirdpassword@a-server.somewhere.com:10255/?ssl=true&replicaSet=globaldb&retrywrites=false&maxIdleTimeMS=120000&appName=@anapphere@';

it('should parse the connection string correctly', () => {
const connStr = new DocumentDBConnectionString(cosmosRUConnectionString);

expect(connStr.username).toBe('auername');
expect(connStr.password).toBe('weirdpassword');
expect(connStr.hosts).toEqual(['a-server.somewhere.com:10255']);
expect(connStr.searchParams.get('ssl')).toBe('true');
expect(connStr.searchParams.get('replicaSet')).toBe('globaldb');
expect(connStr.searchParams.get('retrywrites')).toBe('false');
expect(connStr.searchParams.get('maxIdleTimeMS')).toBe('120000');
expect(connStr.searchParams.get('appName')).toBe('@anapphere@');
});

it('should survive parse/serialize roundtrip', () => {
const connStr = new DocumentDBConnectionString(cosmosRUConnectionString);
const serialized = connStr.toString();

const reparsed = new DocumentDBConnectionString(serialized);

expect(reparsed.username).toBe('auername');
expect(reparsed.password).toBe('weirdpassword');
expect(reparsed.hosts).toEqual(['a-server.somewhere.com:10255']);
expect(reparsed.searchParams.get('ssl')).toBe('true');
expect(reparsed.searchParams.get('replicaSet')).toBe('globaldb');
expect(reparsed.searchParams.get('appName')).toBe('@anapphere@');
});

it('should survive multiple parse/serialize cycles without parameter doubling', () => {
let currentString = cosmosRUConnectionString;

// Simulate 5 migrations/saves
for (let i = 0; i < 5; i++) {
const parsed = new DocumentDBConnectionString(currentString);
currentString = parsed.deduplicateQueryParameters();
}

const finalParsed = new DocumentDBConnectionString(currentString);

// All parameters should appear exactly once
expect(finalParsed.searchParams.getAll('ssl')).toHaveLength(1);
expect(finalParsed.searchParams.getAll('replicaSet')).toHaveLength(1);
expect(finalParsed.searchParams.getAll('retrywrites')).toHaveLength(1);
expect(finalParsed.searchParams.getAll('maxIdleTimeMS')).toHaveLength(1);
expect(finalParsed.searchParams.getAll('appName')).toHaveLength(1);

// Values should be correct
expect(finalParsed.username).toBe('auername');
expect(finalParsed.password).toBe('weirdpassword');
expect(finalParsed.searchParams.get('appName')).toBe('@anapphere@');
});

it('should work correctly when clearing credentials (v1 to v2 migration pattern)', () => {
const connStr = new DocumentDBConnectionString(cosmosRUConnectionString);

// Extract credentials (like v1 to v2 migration does)
const username = connStr.username;
const password = connStr.password;

// Clear credentials
connStr.username = '';
connStr.password = '';

// Get normalized connection string
const normalizedCS = connStr.deduplicateQueryParameters();

// Verify credentials were extracted correctly
expect(username).toBe('auername');
expect(password).toBe('weirdpassword');

// Verify connection string without credentials is valid
const reparsed = new DocumentDBConnectionString(normalizedCS);
expect(reparsed.username).toBe('');
expect(reparsed.password).toBe('');
expect(reparsed.hosts).toEqual(['a-server.somewhere.com:10255']);
expect(reparsed.searchParams.get('appName')).toBe('@anapphere@');
expect(reparsed.searchParams.get('ssl')).toBe('true');
});

it('should not have duplicate parameters', () => {
const connStr = new DocumentDBConnectionString(cosmosRUConnectionString);

expect(connStr.hasDuplicateParameters()).toBe(false);
});
});
});
82 changes: 82 additions & 0 deletions src/documentdb/utils/DocumentDBConnectionString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,86 @@ export class DocumentDBConnectionString extends ConnectionString {
return false;
}
}

/**
* Removes duplicate query parameters from the connection string, keeping only
* the last value for each key. This is useful for cleaning up connection strings
* that may have been corrupted by bugs in previous versions.
*
* Some MongoDB parameters (like `readPreferenceTags`) legitimately allow multiple values.
* This method preserves those by keeping all unique values for each key.
*
* @returns A new connection string with deduplicated parameters
*
* @example
* // Input: mongodb://host/?ssl=true&ssl=true&appName=app
* // Output: mongodb://host/?ssl=true&appName=app
*/
public deduplicateQueryParameters(): string {
// Get all unique keys
const uniqueKeys = [...new Set([...this.searchParams.keys()])];

// For each key, get unique values (preserving order of first occurrence)
const deduplicatedParams: string[] = [];
for (const key of uniqueKeys) {
const allValues = this.searchParams.getAll(key);
// Keep only unique values (in case same key=value appears multiple times)
const uniqueValues = [...new Set(allValues)];

for (const value of uniqueValues) {
// Values from searchParams are already decoded, encode them for the URL
deduplicatedParams.push(`${key}=${encodeURIComponent(value)}`);
}
}

// Reconstruct the connection string
const baseUrl = this.toString().split('?')[0];
if (deduplicatedParams.length === 0) {
return baseUrl;
}
return `${baseUrl}?${deduplicatedParams.join('&')}`;
}

/**
* Checks if the connection string has any duplicate query parameters.
*
* @returns true if there are duplicate parameters (same key with same value appearing multiple times)
*/
public hasDuplicateParameters(): boolean {
const uniqueKeys = [...new Set([...this.searchParams.keys()])];

for (const key of uniqueKeys) {
const allValues = this.searchParams.getAll(key);
const uniqueValues = new Set(allValues);
if (allValues.length !== uniqueValues.size) {
return true;
}
}
return false;
}

/**
* Normalizes a connection string by:
* 1. Removing duplicate query parameters (same key=value pairs)
* 2. Ensuring consistent encoding
*
* This is a static factory method that creates a normalized connection string
* from an input string, useful for cleaning up potentially corrupted data.
*
* @param connectionString - The connection string to normalize
* @returns A normalized connection string, or the original if parsing fails
*/
public static normalize(connectionString: string): string {
if (!connectionString) {
return connectionString;
}

try {
const parsed = new DocumentDBConnectionString(connectionString);
return parsed.deduplicateQueryParameters();
} catch {
// If parsing fails, return the original string
return connectionString;
}
}
}
31 changes: 31 additions & 0 deletions src/documentdb/utils/connectionStringHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,34 @@ export const AzureDomains = {
vCore: 'mongocluster.cosmos.azure.com',
GeneralAzure: 'azure.com',
};

/**
* Normalizes a connection string by removing duplicate query parameters.
* This is useful for cleaning up connection strings that may have been corrupted
* by bugs in previous versions.
*
* @param connectionString - The connection string to normalize
* @returns A normalized connection string with duplicate parameters removed
*/
export const normalizeConnectionString = (connectionString: string): string => {
return DocumentDBConnectionString.normalize(connectionString);
};

/**
* Checks if a connection string has duplicate query parameters.
*
* @param connectionString - The connection string to check
* @returns true if there are duplicate parameters
*/
export const hasDuplicateParameters = (connectionString: string): boolean => {
if (!connectionString) {
return false;
}

try {
const parsed = new DocumentDBConnectionString(connectionString);
return parsed.hasDuplicateParameters();
} catch {
return false;
}
};
Loading
Loading