From e7e8be8f28308c2f702c7b0b12f37063ffa18a54 Mon Sep 17 00:00:00 2001 From: Colin Gillespie Date: Fri, 16 Oct 2026 15:02:47 +0100 Subject: [PATCH 1/4] fix: Count content owned by locked users from the summary, not the content list summarise_users() stored the full content listing, and get_quarto_locked_user_apps() ran if (is.na()) on it, so report.qmd failed to render on every licensed server. It now stores the locked-user counts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BEZ4ZUv51gFQjAe1WePbJF --- R/quarto-helpers.R | 13 ++----------- R/summarise_users.R | 8 ++++---- tests/testthat/test-quarto-helpers.R | 9 +++++++++ 3 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 tests/testthat/test-quarto-helpers.R diff --git a/R/quarto-helpers.R b/R/quarto-helpers.R index 11254a4..0f81491 100644 --- a/R/quarto-helpers.R +++ b/R/quarto-helpers.R @@ -55,15 +55,6 @@ get_quarto_user_roles = function(out) { #' @rdname get_quarto_old_users #' @export get_quarto_locked_user_apps = function(out) { - apps = out$users_details$apps - if (is.na(apps)) { - return(0L) - } - - app_creators = purrr::map_df( - apps, - ~ dplyr::tibble(owner = .x[["owner_username"]], locked = .x$owner_locked) - ) - locked_users = dplyr::filter(app_creators, .data$locked) # nolint: object_usage_linter - nrow(locked_users) + # NULL on an evaluation licence, which skips the user audit + sum(out$users_details$locked_apps$n) } diff --git a/R/summarise_users.R b/R/summarise_users.R index e17cfb3..5880811 100644 --- a/R/summarise_users.R +++ b/R/summarise_users.R @@ -16,12 +16,12 @@ summarise_users = function(server, token, debug_level) { # XXX: I don't think this is right # Evaluation copies should have apps??? if (is_evaluation(settings)) { - apps = NA + locked_apps = NULL } else { print_audit_users(user_list) - apps = print_audit_user_apps(client, debug_level) + locked_apps = print_audit_user_apps(client, debug_level) } - list(user_list = user_list, apps = apps) + list(user_list = user_list, locked_apps = locked_apps) } is_evaluation = function(settings) { @@ -80,5 +80,5 @@ print_audit_user_apps = function(client, debug_level) { "{row$n} locked application{?s} owned by {cli::style_italic(row$username)}" ) } - content + locked_content } diff --git a/tests/testthat/test-quarto-helpers.R b/tests/testthat/test-quarto-helpers.R new file mode 100644 index 0000000..2252f9b --- /dev/null +++ b/tests/testthat/test-quarto-helpers.R @@ -0,0 +1,9 @@ +test_that("get_quarto_locked_user_apps() counts content owned by locked users", { + locked_apps = tibble::tibble(username = c("ann", "bob"), n = c(2L, 1L)) + expect_identical( + get_quarto_locked_user_apps(list(users_details = list(locked_apps = locked_apps))), + 3L + ) + # An evaluation licence skips the user audit + expect_identical(get_quarto_locked_user_apps(list(users_details = list(locked_apps = NULL))), 0L) +}) From e0fcef8f3169fcd007a46e4202e94ad79642e3dc Mon Sep 17 00:00:00 2001 From: Colin Gillespie Date: Fri, 16 Oct 2026 15:03:05 +0100 Subject: [PATCH 2/4] fix: Keep the Connect API key off the command line and out of rsconnect's config deploy_python() passed --api-key to rsconnect, where any user on the machine could read it with ps. It now goes through CONNECT_SERVER and CONNECT_API_KEY. check() also gives rsconnect a temporary config folder, so the account file holding the key is deleted after the audit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BEZ4ZUv51gFQjAe1WePbJF --- DESCRIPTION | 4 ++-- R/check.R | 2 ++ R/deploy_python.R | 8 +++----- tests/testthat/test-deploy-python.R | 7 +++++-- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 26ee203..6fd1ddb 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -25,14 +25,14 @@ Imports: rlang, rsconnect (>= 1.10.0), stringr, - tibble + tibble, + withr Suggests: glue, gt, gtExtras, htmltools, testthat (>= 3.0.0), - withr, yaml Remotes: jumpingrivers/audit.base, diff --git a/R/check.R b/R/check.R index 075f0fb..ee3e5af 100644 --- a/R/check.R +++ b/R/check.R @@ -17,6 +17,8 @@ #' @export check = function(server = NULL, token = NULL, dir = ".", debug_level = 0:2) { debug_level = get_debug_level(force(debug_level)) + # rsconnect saves the account, API key included, in its config folder; use a temporary one + withr::local_envvar(R_USER_CONFIG_DIR = withr::local_tempdir()) check_list = list() check_list$setup = summarise_setup(server, token) check_list$audit_details = audit_details(get_server(), get_token()) diff --git a/R/deploy_python.R b/R/deploy_python.R index e9ccff0..4dc03b0 100644 --- a/R/deploy_python.R +++ b/R/deploy_python.R @@ -43,15 +43,13 @@ deploy_python = function( rsconnect_type, # Quarto and Voila deploy a document; the apps deploy the directory if (grepl("\\.(qmd|ipynb)$", python_files[1])) python_files[1] else ".", - "--server", - get_server(), - "--api-key", - get_token(), "--new", "--title", title ), - wd = tmp_dir + wd = tmp_dir, + # Not as arguments, which other users on the machine can read with ps + env = c("current", CONNECT_SERVER = get_server(), CONNECT_API_KEY = get_token()) ) ) invisible(has_deployed$status == 0) diff --git a/tests/testthat/test-deploy-python.R b/tests/testthat/test-deploy-python.R index cbbef2d..0e98b3d 100644 --- a/tests/testthat/test-deploy-python.R +++ b/tests/testthat/test-deploy-python.R @@ -24,7 +24,7 @@ test_that("A failed Python deploy still deletes the content Connect created", { expect_identical(calls$deleted, "a") }) -test_that("Python deploys let Connect choose the Python version", { +test_that("Python deploys let Connect choose the Python and keep the key off the command line", { calls = new.env() testthat::local_mocked_bindings( get_server = function(...) "https://connect.example.com/", @@ -33,8 +33,9 @@ test_that("Python deploys let Connect choose the Python version", { cleanup_python = function(...) invisible(NULL) ) testthat::local_mocked_bindings( - run = function(command, args, wd, ...) { + run = function(command, args, wd, env, ...) { calls$args = args + calls$env = env calls$pyproject = readLines(file.path(wd, "pyproject.toml")) list(status = 0L) }, @@ -45,4 +46,6 @@ test_that("Python deploys let Connect choose the Python version", { expect_true(deploy_python(python_dir, "hash.ipynb", "voila", debug_level = 0)) expect_true('requires-python = ">=3.10"' %in% calls$pyproject) expect_identical(calls$args[1:3], c("deploy", "voila", "hash.ipynb")) + expect_false("key" %in% calls$args) + expect_identical(calls$env[["CONNECT_API_KEY"]], "key") }) From d4eefe4aad49e6f0278bc34f2d400d87e4e52a1a Mon Sep 17 00:00:00 2001 From: Colin Gillespie Date: Fri, 16 Oct 2026 15:03:05 +0100 Subject: [PATCH 3/4] fix: Log the Python checks as skipped when rsconnect-python is missing The Flask check fell through to the deploy and the Quarto Python check had no skip, so both were reported as failed; Streamlit and Voila returned before writing a log row and were missing from the results. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BEZ4ZUv51gFQjAe1WePbJF --- R/deploy_flask-r6.R | 5 ++--- R/deploy_python.R | 11 +++++++++++ R/deploy_quarto-r6.R | 4 ++-- R/deploy_streamlit-r6.R | 3 +-- R/deploy_voila-r6.R | 3 +-- tests/testthat/test-deploy-python.R | 18 ++++++++++++++++++ 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/R/deploy_flask-r6.R b/R/deploy_flask-r6.R index 3f55995..efc7300 100644 --- a/R/deploy_flask-r6.R +++ b/R/deploy_flask-r6.R @@ -9,9 +9,8 @@ check_deploy_python_flask = R6::R6Class( #' @description Checks deployment of Quarto document with HTML output #' @param debug_level See check() for details check = function(debug_level) { - if (isFALSE(.connect$rsconnect_python)) { - cli::cli_alert_info("rsconnect-python missing. Skipping Python test.") - invisible(NULL) + if (skip_without_rsconnect_python(self)) { + return(invisible(NULL)) } python_dir = system.file( "extdata", diff --git a/R/deploy_python.R b/R/deploy_python.R index 4dc03b0..d8abe3a 100644 --- a/R/deploy_python.R +++ b/R/deploy_python.R @@ -81,3 +81,14 @@ cleanup_python = function(tmp_dir, debug_level, title) { invisible(NULL) } + +# Log the check as skipped, so the report lists it, when rsconnect-python is missing +skip_without_rsconnect_python = function(check) { + if (!isFALSE(.connect$rsconnect_python)) { + return(FALSE) + } + cli::cli_alert_info("rsconnect-python missing. Skipping Python test.") + check$start_logger() + check$stop_logger(NA) + TRUE +} diff --git a/R/deploy_quarto-r6.R b/R/deploy_quarto-r6.R index c4ed09d..7daa09f 100644 --- a/R/deploy_quarto-r6.R +++ b/R/deploy_quarto-r6.R @@ -64,8 +64,8 @@ check_deploy_quarto_python = R6::R6Class( #' @param account Connect username #' @param debug_level See check() for details check = function(debug_level, account = NULL) { - if (is.null(account)) { - account = get_username() + if (skip_without_rsconnect_python(self)) { + return(invisible(NULL)) } python_dir = system.file( "extdata", diff --git a/R/deploy_streamlit-r6.R b/R/deploy_streamlit-r6.R index 073dd26..f2d378c 100644 --- a/R/deploy_streamlit-r6.R +++ b/R/deploy_streamlit-r6.R @@ -9,8 +9,7 @@ check_deploy_python_streamlit = R6::R6Class( #' @description Checks deployment of Python Streamlit app #' @param debug_level See check() for details check = function(debug_level) { - if (isFALSE(.connect$rsconnect_python)) { - cli::cli_alert_info("rsconnect-python missing. Skipping Python test.") + if (skip_without_rsconnect_python(self)) { return(invisible(NULL)) } python_dir = system.file( diff --git a/R/deploy_voila-r6.R b/R/deploy_voila-r6.R index 641eb91..4b2b114 100644 --- a/R/deploy_voila-r6.R +++ b/R/deploy_voila-r6.R @@ -9,8 +9,7 @@ check_deploy_python_voila = R6::R6Class( #' @description Checks deployment of a Voila notebook #' @param debug_level See check() for details check = function(debug_level) { - if (isFALSE(.connect$rsconnect_python)) { - cli::cli_alert_info("rsconnect-python missing. Skipping Python test.") + if (skip_without_rsconnect_python(self)) { return(invisible(NULL)) } python_dir = system.file( diff --git a/tests/testthat/test-deploy-python.R b/tests/testthat/test-deploy-python.R index 0e98b3d..debbe4d 100644 --- a/tests/testthat/test-deploy-python.R +++ b/tests/testthat/test-deploy-python.R @@ -49,3 +49,21 @@ test_that("Python deploys let Connect choose the Python and keep the key off the expect_false("key" %in% calls$args) expect_identical(calls$env[["CONNECT_API_KEY"]], "key") }) + +test_that("Python checks are logged as skipped without rsconnect-python", { + old = .connect$rsconnect_python + withr::defer(assign("rsconnect_python", old, envir = .connect)) + .connect$rsconnect_python = FALSE + testthat::local_mocked_bindings(deploy_python = function(...) stop("should not deploy")) + checks = list( + check_deploy_python_flask, + check_deploy_python_streamlit, + check_deploy_python_voila, + check_deploy_quarto_python + ) + for (check in checks) { + obj = check$new(dir = withr::local_tempdir()) + obj$check(debug_level = 0) + expect_true(is.na(obj$get_log()$passed)) + } +}) From 60987a8835121135840c9b9e0f74031cc0bcd7f6 Mon Sep 17 00:00:00 2001 From: Colin Gillespie Date: Fri, 16 Oct 2026 15:03:22 +0100 Subject: [PATCH 4/4] chore: Bump version to 0.8.8 --- DESCRIPTION | 2 +- NEWS.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 6fd1ddb..69db657 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Type: Package Package: audit.connect Title: Posit Connect Health Check -Version: 0.8.7 +Version: 0.8.8 Authors@R: person("Jumping", "Rivers", , "info@jumpingrivers.com", role = c("aut", "cre")) Description: Posit Connect Health Check. Deploys various content types to diff --git a/NEWS.md b/NEWS.md index e680557..e5c77a5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,11 @@ +# audit.connect 0.8.8 + +## Bug fixes and minor improvements + +* `report.qmd` renders on licensed servers. The summary stores how much content each locked user owns, not the full content list (#64). +* The Python checks are reported as skipped when rsconnect-python is missing, instead of two failing and two being left out (#65). +* The API key is passed to rsconnect-python through `CONNECT_API_KEY`, not on the command line, and the rsconnect account that holds it is kept in a temporary folder (#66). + # audit.connect 0.8.7 ## Bug fixes and minor improvements