diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index 52f648cfb9..6c2eaed95a 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -62,6 +62,10 @@ export interface MongoVersionCommand { kind: "version"; } +export interface MongoShowDatabasesCommand { + kind: "showDatabases"; +} + export interface MongoCreateUserCommand { userJson: string; writeConcernJson?: string; @@ -91,6 +95,7 @@ export type MongoCommand = | ({ kind: "find" } & MongoFindCommand) | ({ kind: "findOne" } & MongoFindOneCommand) | MongoVersionCommand + | MongoShowDatabasesCommand | ({ kind: "countDocuments" } & MongoCountDocumentsCommand) | ({ kind: "aggregate" } & MongoAggregateCommand) | ({ kind: "distinct" } & MongoDistinctCommand) @@ -619,6 +624,7 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null { // Keep the more specific readers ahead of generic write parsing so the // returned kind matches the result renderer we want to use downstream. const parsers: Array<(source: string) => MongoCommand | null> = [ + parseMongoShowDatabasesCommand, (source) => { const version = parseMongoVersionCommand(source); return version ?? null; @@ -691,6 +697,11 @@ export function parseMongoCommand(input: string): ParsedMongoCommand | null { return null; } +export function parseMongoShowDatabasesCommand(input: string): MongoShowDatabasesCommand | null { + const source = input.trim().replace(/;$/, "").trim(); + return /^show\s+(?:dbs|databases)$/i.test(source) ? { kind: "showDatabases" } : null; +} + export function splitMongoCommands(input: string): ParsedMongoCommand[] { return splitMongoCommandRanges(input).map(({ from: _from, to: _to, ...command }) => command); } @@ -786,6 +797,27 @@ export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeM }; } +export function mongoDatabasesToQueryResult(documents: unknown[], executionTimeMs: number, maxRows: number): QueryResult { + const response = documents[0]; + const databases = isRecord(response) ? response.databases : undefined; + if (!Array.isArray(databases)) throw new Error("MongoDB listDatabases response is missing the databases array."); + if (!databases.every(isRecord)) { + throw new Error("MongoDB listDatabases response contains an invalid database entry."); + } + + const rowLimit = Number.isFinite(maxRows) ? Math.max(1, Math.trunc(maxRows)) : databases.length; + const rows = databases.slice(0, rowLimit).map((database) => [toCellValue(database.name), toCellValue(database.sizeOnDisk), toCellValue(database.empty)]); + const truncated = rows.length < databases.length; + return { + columns: ["name", "sizeOnDisk", "empty"], + rows, + affected_rows: databases.length, + execution_time_ms: Math.max(0, Math.round(executionTimeMs)), + truncated, + has_more: truncated, + }; +} + export function mongoDistinctToQueryResult(field: string, values: unknown[], executionTimeMs: number): QueryResult { return { columns: [field], @@ -1110,7 +1142,7 @@ function mongoTopLevelCommandLineStarts(segment: string): number[] { function isMongoCommandLineStart(segment: string, index: number): boolean { const rest = segment.slice(index); - return /^use\b/i.test(rest) || /^db(?:\s*\.|\b)/i.test(rest); + return /^use\b/i.test(rest) || /^show\s+(?:dbs|databases)\b/i.test(rest) || /^db(?:\s*\.|\b)/i.test(rest); } function pushMongoSegment(segments: MongoTextRange[], source: string, from: number, to: number) { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index b8852f79b4..3916e67167 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -15,6 +15,7 @@ import { evaluateMongoWriteSafety, mongoCollectionStatsToQueryResult, mongoCountToQueryResult, + mongoDatabasesToQueryResult, mongoDistinctToQueryResult, mongoCreateIndexToQueryResult, mongoDocumentsToQueryResult, @@ -4092,6 +4093,7 @@ export const useQueryStore = defineStore("query", () => { queryExecutionLog("info", "mongo:start", { traceId, commandCount: mongoCommands.length, sqlLength: sql.length }); const allResults: QueryResult[] = []; + const mongoResultMaxRows = effectiveQueryResultMaxRows(settingsStore.editorSettings.queryResultMaxRowsEnabled, settingsStore.editorSettings.queryResultMaxRows); // Track the effective db as we walk the batch so later commands observe // earlier `use ...` statements in the same editor selection. let currentDatabase = executionTarget?.database ?? tab.database; @@ -4175,6 +4177,19 @@ export const useQueryStore = defineStore("query", () => { }); break; } + case "showDatabases": { + queryExecutionLog("info", "mongo-show-databases:start", { traceId }); + const result = await api.mongoRunCommand(executionConnectionId, "admin", '{"listDatabases":1}', executionId); + const queryResult = mongoDatabasesToQueryResult(result.documents, performance.now() - commandStartedAt, agentProtocolQueryResultMaxRows(mongoResultMaxRows)); + allResults.push(markQueryResultRowsRaw(annotateMongoResult(queryResult))); + mongoEditTarget = undefined; + queryExecutionLog("info", "mongo-show-databases:done", { + traceId, + databaseCount: queryResult.affected_rows, + elapsed: elapsed(), + }); + break; + } case "countDocuments": { queryExecutionLog("info", "mongo-count:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); const total = await api.mongoCountDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.mode, executionId); diff --git a/crates/dbx-core/src/mongo_ops.rs b/crates/dbx-core/src/mongo_ops.rs index ae45fb8ec3..9e4a04688a 100644 --- a/crates/dbx-core/src/mongo_ops.rs +++ b/crates/dbx-core/src/mongo_ops.rs @@ -8,6 +8,9 @@ use crate::document_ops::CollectionInfo; use crate::mongo_shell::MongoCommand; use crate::types::{IndexInfo, QueryResult}; +pub const MONGO_SHOW_DATABASES_DATABASE: &str = "admin"; +pub const MONGO_SHOW_DATABASES_COMMAND_JSON: &str = r#"{"listDatabases":1}"#; + async fn ensure_document_pool(state: &AppState, connection_id: &str) -> Result<(), String> { state.get_or_create_pool(connection_id, None).await.map(|_| ()) } @@ -177,6 +180,10 @@ pub async fn mongo_run_command_core( } } +pub async fn mongo_show_databases_core(state: &AppState, connection_id: &str) -> Result { + mongo_run_command_core(state, connection_id, MONGO_SHOW_DATABASES_DATABASE, MONGO_SHOW_DATABASES_COMMAND_JSON).await +} + pub async fn mongo_collection_stats_core( state: &AppState, connection_id: &str, @@ -918,6 +925,10 @@ pub async fn execute_mongo_command_core( .await .map(|version| scalar_query_result("version", Value::String(version))), MongoCommand::Use { database } => Ok(scalar_query_result("database", Value::String(database.clone()))), + MongoCommand::ShowDatabases => { + let result = mongo_show_databases_core(state, connection_id).await?; + mongo_show_databases_query_result(result.documents, max_rows) + } MongoCommand::RunCommand { command_json } => { let result = mongo_run_command_core(state, connection_id, database, command_json).await?; Ok(mongo_documents_query_result(result.documents)) @@ -1233,6 +1244,41 @@ fn mongo_documents_query_result(documents: Vec) -> QueryResul query_result(columns, rows, 0) } +pub fn mongo_show_databases_query_result( + documents: Vec, + max_rows: usize, +) -> Result { + use serde_json::Value; + + let databases = documents + .first() + .and_then(Value::as_object) + .and_then(|response| response.get("databases")) + .and_then(Value::as_array) + .ok_or("MongoDB listDatabases response is missing the databases array")?; + if databases.iter().any(|database| !database.is_object()) { + return Err("MongoDB listDatabases response contains an invalid database entry".to_string()); + } + + let total = databases.len(); + let rows = databases + .iter() + .take(max_rows.max(1)) + .map(|database| { + let database = database.as_object().expect("database entries were validated above"); + ["name", "sizeOnDisk", "empty"].map(|field| database.get(field).cloned().unwrap_or(Value::Null)).to_vec() + }) + .collect::>(); + let mut query_result = query_result( + vec!["name".to_string(), "sizeOnDisk".to_string(), "empty".to_string()], + rows, + u64::try_from(total).unwrap_or(u64::MAX), + ); + query_result.truncated = query_result.rows.len() < total; + query_result.has_more = query_result.truncated; + Ok(query_result) +} + #[cfg(test)] mod tests { use super::*; @@ -1261,6 +1307,63 @@ mod tests { assert_eq!(limited.extended_documents.unwrap(), vec![serde_json::json!(1), serde_json::json!(2)]); } + #[test] + fn show_databases_result_preserves_metadata_and_row_limit() { + let result = MongoDocumentResult { + documents: vec![serde_json::json!({ + "databases": [ + {"name": "admin", "sizeOnDisk": 40960, "empty": false}, + {"name": "app", "sizeOnDisk": 8192, "empty": true}, + {"name": "logs", "sizeOnDisk": 1024, "empty": false} + ], + "totalSize": 50176, + "ok": 1 + })], + raw_documents: None, + extended_documents: None, + total: 1, + total_is_exact: true, + }; + + let query_result = mongo_show_databases_query_result(result.documents, 2).unwrap(); + + assert_eq!(query_result.columns, ["name", "sizeOnDisk", "empty"]); + assert_eq!( + query_result.rows, + [ + [serde_json::json!("admin"), serde_json::json!(40960), serde_json::json!(false)], + [serde_json::json!("app"), serde_json::json!(8192), serde_json::json!(true)], + ] + ); + assert_eq!(query_result.affected_rows, 3); + assert!(query_result.truncated); + assert!(query_result.has_more); + } + + #[test] + fn show_databases_result_handles_empty_and_rejects_malformed_responses() { + let empty = MongoDocumentResult { + documents: vec![serde_json::json!({"databases": [], "ok": 1})], + raw_documents: None, + extended_documents: None, + total: 1, + total_is_exact: true, + }; + let query_result = mongo_show_databases_query_result(empty.documents, 100).unwrap(); + assert_eq!(query_result.columns, ["name", "sizeOnDisk", "empty"]); + assert!(query_result.rows.is_empty()); + assert!(!query_result.truncated); + + let malformed = MongoDocumentResult { + documents: vec![serde_json::json!({"ok": 1})], + raw_documents: None, + extended_documents: None, + total: 1, + total_is_exact: true, + }; + assert!(mongo_show_databases_query_result(malformed.documents, 100).unwrap_err().contains("databases array")); + } + #[cfg(unix)] use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec}; #[cfg(unix)] @@ -1365,6 +1468,7 @@ EXPECTED_RESULT = json.loads({expected_result}) CAPABILITIES = {capabilities} SERVER_VERSION = {server_version} EXPECTED_ERROR = {expected_error} +expected_calls = 0 print(json.dumps({{"ready": True}}), flush=True) for line in sys.stdin: @@ -1382,6 +1486,10 @@ for line in sys.stdin: if request.get("method") != EXPECTED_METHOD or request.get("params") != EXPECTED_PARAMS: print(json.dumps({{"jsonrpc": "2.0", "id": request["id"], "error": {{"code": -1, "message": "unexpected MongoDB RPC"}}}}), flush=True) continue + expected_calls += 1 + if expected_calls > 1: + print(json.dumps({{"jsonrpc": "2.0", "id": request["id"], "error": {{"code": -1, "message": "duplicate MongoDB RPC"}}}}), flush=True) + continue if EXPECTED_ERROR is not None: print(json.dumps({{"jsonrpc": "2.0", "id": request["id"], "error": {{"code": -1, "message": EXPECTED_ERROR}}}}), flush=True) else: @@ -1477,6 +1585,37 @@ for line in sys.stdin: ); } + #[cfg(unix)] + #[tokio::test] + async fn mongo_show_databases_uses_one_admin_agent_command() { + let expected_result = serde_json::json!({ + "documents": [{ + "databases": [{"name": "admin", "sizeOnDisk": 40960, "empty": false}], + "ok": 1 + }], + "total": 1, + }); + let (state, _directory) = legacy_mongo_state_with_capabilities( + "run_command", + serde_json::json!({ + "database": "admin", + "command_json": "{\"listDatabases\":1}", + }), + expected_result, + &[AgentCapability::MongoRunCommand.as_str()], + ) + .await; + + let query_result = + execute_mongo_command_core(&state, "legacy", "ignored-current-database", &MongoCommand::ShowDatabases, 100) + .await + .unwrap(); + + assert_eq!(query_result.columns, ["name", "sizeOnDisk", "empty"]); + assert_eq!(query_result.rows.len(), 1); + assert_eq!(query_result.rows[0][0], "admin"); + } + #[cfg(unix)] #[tokio::test] async fn mongo_run_command_requires_an_explicit_legacy_agent_capability() { diff --git a/crates/dbx-core/src/mongo_shell.rs b/crates/dbx-core/src/mongo_shell.rs index a52f81dfd5..45977017ae 100644 --- a/crates/dbx-core/src/mongo_shell.rs +++ b/crates/dbx-core/src/mongo_shell.rs @@ -8,6 +8,8 @@ pub enum MongoCommand { Version, #[serde(rename = "use")] Use { database: String }, + #[serde(rename = "showDatabases")] + ShowDatabases, #[serde(rename = "runCommand")] RunCommand { #[serde(rename = "commandJson")] @@ -355,6 +357,10 @@ fn mongo_field_predicate_is_exists_true(value: &serde_json::Value) -> bool { } pub fn parse(input: &str) -> Result { + let show_source = trim_mongo_outer_comments(input).trim_end_matches(';').trim(); + if parse_show_databases(show_source) { + return Ok(MongoCommand::ShowDatabases); + } let source = input.trim().trim_end_matches(';').trim(); if source.eq_ignore_ascii_case("db.version()") { return Ok(MongoCommand::Version); @@ -645,6 +651,86 @@ pub fn parse(input: &str) -> Result { Err("Unsupported MongoDB shell command.".to_string()) } +fn parse_show_databases(source: &str) -> bool { + let mut words = source.split_whitespace(); + words.next().is_some_and(|word| word.eq_ignore_ascii_case("show")) + && words.next().is_some_and(|word| word.eq_ignore_ascii_case("dbs") || word.eq_ignore_ascii_case("databases")) + && words.next().is_none() +} + +fn trim_mongo_outer_comments(mut source: &str) -> &str { + loop { + source = source.trim_start(); + if source.starts_with("//") || source.starts_with("--") { + source = source + .char_indices() + .find_map(|(index, character)| (character == '\n' || character == '\r').then_some(&source[index + 1..])) + .unwrap_or(""); + continue; + } + if source.starts_with("/*") { + let Some(end) = source.find("*/") else { + return source; + }; + source = &source[end + 2..]; + continue; + } + break; + } + + let source = source.trim_end(); + let mut body_end = 0; + let mut quote = None; + let mut escaped = false; + let mut line_comment = false; + let mut block_comment = false; + let mut characters = source.char_indices().peekable(); + while let Some((index, character)) = characters.next() { + let next = characters.peek().map(|(_, character)| *character); + if line_comment { + if character == '\n' || character == '\r' { + line_comment = false; + } + continue; + } + if block_comment { + if character == '*' && next == Some('/') { + block_comment = false; + characters.next(); + } + continue; + } + if let Some(delimiter) = quote { + body_end = index + character.len_utf8(); + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == delimiter { + quote = None; + } + continue; + } + if (character == '/' && next == Some('/')) || (character == '-' && next == Some('-')) { + line_comment = true; + characters.next(); + continue; + } + if character == '/' && next == Some('*') { + block_comment = true; + characters.next(); + continue; + } + if matches!(character, '"' | '\'' | '`') { + quote = Some(character); + } + if !character.is_whitespace() { + body_end = index + character.len_utf8(); + } + } + source[..body_end].trim_end() +} + fn parse_collection_prefix(source: &str) -> Result<(String, usize), String> { if !source.get(..3).is_some_and(|prefix| prefix.eq_ignore_ascii_case("db.")) { return Err("MongoDB command must start with db..".to_string()); @@ -1064,6 +1150,27 @@ mod tests { assert_eq!(validate_safety(&command, true, true, false), Ok(())); } + #[test] + fn parses_show_databases_aliases_as_read_only() { + for source in [ + "show dbs", + "SHOW DATABASES;", + "ShOw DbS ;", + "/* databases */ show dbs -- list", + "// databases\nshow databases; /* list */", + ] { + let command = parse(source).unwrap(); + assert_eq!(command, MongoCommand::ShowDatabases, "{source}"); + assert!(!command.is_mutating(), "{source}"); + assert!(!command.is_dangerous(), "{source}"); + assert_eq!(validate_safety(&command, false, false, true), Ok(()), "{source}"); + } + + for source in ["show dbs extra", "show database", "show collections", "show"] { + assert!(parse(source).is_err(), "{source}"); + } + } + #[test] fn rejects_unsupported_run_command_shapes() { for source in [ @@ -1230,6 +1337,8 @@ mod tests { let run_command = serde_json::to_value(parse("db.runCommand({ping: 1})").unwrap()).unwrap(); assert_eq!(run_command["kind"], "runCommand"); assert_eq!(run_command["commandJson"], r#"{"ping":1}"#); + let show_databases = serde_json::to_value(parse("show dbs").unwrap()).unwrap(); + assert_eq!(show_databases["kind"], "showDatabases"); } #[test] diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index a1e65232cd..647884b6f5 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -1006,6 +1006,23 @@ impl DbxBackend for WebBackend { Ok(scalar_query_result("version", Value::String(version))) } MongoCommand::Use { database } => Ok(scalar_query_result("database", Value::String(database.clone()))), + MongoCommand::ShowDatabases => { + let result = self + .request( + reqwest::Method::POST, + "/api/mongo/run-command", + Some(json!({ + "connectionId": connection_id, + "database": dbx_core::mongo_ops::MONGO_SHOW_DATABASES_DATABASE, + "commandJson": dbx_core::mongo_ops::MONGO_SHOW_DATABASES_COMMAND_JSON, + })), + ) + .await? + .json::() + .await + .map_err(|error| format!("Invalid MongoDB listDatabases response: {error}"))?; + dbx_core::mongo_ops::mongo_show_databases_query_result(result.documents, 100) + } MongoCommand::RunCommand { .. } => { Err("MongoDB runCommand is not available through the DBX MCP backend".to_string()) } @@ -1863,6 +1880,86 @@ mod tests { assert_eq!(result.affected_rows, 1); } + #[tokio::test] + async fn web_mongo_show_databases_uses_one_admin_read_command() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (request_sender, request_receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + let header_end = loop { + let count = stream.read(&mut buffer).unwrap(); + request.extend_from_slice(&buffer[..count]); + if let Some(position) = request.windows(4).position(|window| window == b"\r\n\r\n") { + break position + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length").then_some(value.trim()) + }) + .unwrap() + .parse::() + .unwrap(); + while request.len() < header_end + content_length { + let count = stream.read(&mut buffer).unwrap(); + request.extend_from_slice(&buffer[..count]); + } + let request = String::from_utf8(request).unwrap(); + request_sender + .send(( + request.lines().next().unwrap().to_string(), + request[header_end..header_end + content_length].to_string(), + )) + .unwrap(); + + let response_body = r#"{"documents":[{"databases":[{"name":"admin","sizeOnDisk":40960,"empty":false},{"name":"app","sizeOnDisk":8192,"empty":true}],"ok":1}]}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ) + .unwrap(); + }); + + let backend = WebBackend::new(format!("http://{address}"), String::new()).unwrap(); + backend.auth.lock().await.checked = true; + let connection = new_connection_config( + "legacy".to_string(), + "Legacy MongoDB".to_string(), + DatabaseType::MongoDb, + "localhost".to_string(), + 27017, + String::new(), + String::new(), + Some("app".to_string()), + false, + Some("mongodb-legacy".to_string()), + ) + .unwrap(); + backend.connected.lock().await.insert(connection.id.clone(), connection.clone()); + + let result = backend.execute_mongo_command(&connection, "app", &MongoCommand::ShowDatabases).await.unwrap(); + + server.join().unwrap(); + let (request_line, body) = request_receiver.recv().unwrap(); + assert_eq!(request_line, "POST /api/mongo/run-command HTTP/1.1"); + let request: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(request["connectionId"], "legacy"); + assert_eq!(request["database"], "admin"); + assert_eq!(request["commandJson"], r#"{"listDatabases":1}"#); + assert_eq!(result.columns, ["name", "sizeOnDisk", "empty"]); + assert_eq!(result.rows.len(), 2); + assert_eq!(result.affected_rows, 2); + } + #[tokio::test] async fn web_mongo_find_explain_uses_explain_endpoint_and_preserves_options() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index 0be8a33ce4..d2b08f9474 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -7,6 +7,7 @@ import { mongoAggregateWriteStage, mongoCollectionStatsToQueryResult, mongoCountToQueryResult, + mongoDatabasesToQueryResult, mongoDistinctToQueryResult, mongoDocumentsToQueryResult, mongoDroppedIndexesToQueryResult, @@ -106,6 +107,66 @@ test("parseMongoRunCommand normalizes one non-empty command document", () => { } }); +test("parseMongoCommand accepts read-only show-databases aliases exactly", () => { + for (const source of ["show dbs", "SHOW DATABASES;", "/* databases */ show dbs -- list"]) { + assert.deepEqual(parseMongoCommand(source)?.command, { kind: "showDatabases" }, source); + } + + for (const source of ["show dbs extra", "show database", "show collections", "show"]) { + assert.equal(parseMongoCommand(source), null, source); + } + + assert.equal(evaluateMongoWriteSafety(parseMongoCommand("db.runCommand({listDatabases: 1})")!.command as MongoWriteCommand, {}).allowed, false); +}); + +test("splitMongoCommands keeps show databases after a database switch", () => { + assert.deepEqual( + splitMongoCommands("use app\nshow databases").map(({ command }) => command.kind), + ["use", "showDatabases"], + ); +}); + +test("mongoDatabasesToQueryResult preserves metadata and bounds rows", () => { + assert.deepEqual( + mongoDatabasesToQueryResult( + [ + { + databases: [ + { name: "admin", sizeOnDisk: 40960, empty: false }, + { name: "app", sizeOnDisk: { $numberLong: "8192" }, empty: true }, + { name: "logs", sizeOnDisk: 1024, empty: false }, + ], + totalSize: 50176, + ok: 1, + }, + ], + 4.4, + 2, + ), + { + columns: ["name", "sizeOnDisk", "empty"], + rows: [ + ["admin", 40960, false], + ["app", "8192", true], + ], + affected_rows: 3, + execution_time_ms: 4, + truncated: true, + has_more: true, + }, + ); + + assert.deepEqual(mongoDatabasesToQueryResult([{ databases: [] }], 0, 10), { + columns: ["name", "sizeOnDisk", "empty"], + rows: [], + affected_rows: 0, + execution_time_ms: 0, + truncated: false, + has_more: false, + }); + assert.throws(() => mongoDatabasesToQueryResult([{ ok: 1 }], 0, 10), /databases array/); +}); + test("runCommand support does not accept arbitrary Mongo shell JavaScript", () => { assert.deepEqual( splitMongoCommands(`for (let i = 0; i < 2; i += 1) { diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index cb3d962ba5..4f1480427f 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -5073,6 +5073,67 @@ test("mongo runCommand execution follows use and preserves document results", as } }); +test("mongo show dbs executes one bounded read-only admin command", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const runCommandBodies: any[] = []; + + connectionStore.addEphemeralConnection({ + ...conn("mongo-1"), + db_type: "mongodb", + port: 27017, + }); + + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + if (String(input) === "/api/mongo/run-command") { + runCommandBodies.push(JSON.parse(String(init?.body ?? "{}"))); + return Response.json({ + documents: [ + { + databases: [ + { name: "admin", sizeOnDisk: 40960, empty: false }, + { name: "app", sizeOnDisk: 8192, empty: true }, + ], + totalSize: 49152, + ok: 1, + }, + ], + total: 1, + total_is_exact: true, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("mongo-1", "accounting", "Query", "query", ""); + await store.executeTabSql(tabId, "show dbs"); + const tab = store.tabs.find((item) => item.id === tabId); + + assert.equal(runCommandBodies.length, 1); + assert.deepEqual(runCommandBodies[0], { + connectionId: "mongo-1", + database: "admin", + commandJson: '{"listDatabases":1}', + executionId: runCommandBodies[0].executionId, + }); + assert.equal(typeof runCommandBodies[0].executionId, "string"); + assert.deepEqual(tab?.result?.columns, ["name", "sizeOnDisk", "empty"]); + assert.deepEqual(tab?.result?.rows, [ + ["admin", 40960, false], + ["app", 8192, true], + ]); + assert.equal(tab?.result?.affected_rows, 2); + assert.equal(tab?.mongoEditTarget, undefined); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("mongo dropIndex execution uses the dedicated drop-indexes endpoint", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia());