diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 89c42b0..21c6b1a 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -21,13 +21,15 @@ jobs: - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} - {os: ubuntu-latest, r: 'release'} - {os: ubuntu-latest, r: 'oldrel-1'} + # Minimum R in DESCRIPTION + - {os: ubuntu-latest, r: '4.1'} env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} R_KEEP_PKG_SOURCE: yes steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - uses: r-lib/actions/setup-pandoc@v2 diff --git a/DESCRIPTION b/DESCRIPTION index f40af20..f2cc385 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Type: Package Package: audit.base Title: Base package for Posit Checks -Version: 0.7.10 +Version: 0.7.11 Authors@R: person("Jumping", "Rivers", , "info@jumpingrivers.com", role = c("aut", "cre")) Description: Base package for sharing classes between posit audit diff --git a/NEWS.md b/NEWS.md index dcb0fa1..e768f2b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,6 @@ +# audit.base 0.7.11 _2026-10-16_ + * chore: The sys-deps check and the R, Python, Quarto and Posit version lookups are tested offline against fixtures, and CI also checks on R 4.1, the minimum version (#64) + # audit.base 0.7.10 _2026-10-16_ * chore: The package is MIT licensed (#63) diff --git a/R/get_latest_versions_remote.R b/R/get_latest_versions_remote.R index 0fe6655..0f97d52 100644 --- a/R/get_latest_versions_remote.R +++ b/R/get_latest_versions_remote.R @@ -22,9 +22,7 @@ get_latest_versions_remote = function() { # The latest patch of each Quarto minor release. Most releases are prereleases, so the newest 100 # reach back several minors; older minors come from the fixed list get_quarto_versions = function() { - releases = jsonlite::read_json( - "https://api.github.com/repos/quarto-dev/quarto-cli/releases?per_page=100" - ) + releases = fetch_json("https://api.github.com/repos/quarto-dev/quarto-cli/releases?per_page=100") stable = purrr::discard(releases, ~ isTRUE(.x$prerelease)) tags = stringr::str_remove(purrr::map_chr(stable, "tag_name"), "^v") old = c("1.0.38", "1.1.189", "1.2.475", "1.3.450", "1.4.557", "1.5.57", "1.6.43") @@ -42,7 +40,7 @@ latest_patch_per_minor = function(versions) { get_latest_versions_from_posit = function(type = c("r", "python")) { type = match.arg(type) url = glue::glue("https://cdn.posit.co/{type}/versions.json") - versions = unlist(jsonlite::read_json(url)) + versions = unlist(fetch_json(url)) versions = unname(versions) tibble::tibble( patch = get_patch(versions), @@ -56,3 +54,8 @@ get_latest_versions_from_posit = function(type = c("r", "python")) { dplyr::slice(1) %>% dplyr::pull(versions) } + +# JSON from a URL as nested lists +fetch_json = function(url) { + jsonlite::fromJSON(url, simplifyVector = FALSE) +} diff --git a/R/get_pkg_requirements.R b/R/get_pkg_requirements.R index 5859b2d..d2a9681 100644 --- a/R/get_pkg_requirements.R +++ b/R/get_pkg_requirements.R @@ -14,12 +14,10 @@ get_pkg_requirements = function( "Connecting to {url} for system requirements" ) - res = httr::GET(url, httr::timeout(60)) - r = httr::content(res) - if (httr::http_error(res)) { - reason = if (is.list(r) && is.character(r$error)) r$error else httr::http_status(res)$message # nolint + r = read_p3m(url) + if (!is.null(r$error)) { cli::cli_warn( - "Posit Package Manager can't list system requirements for {distribution} {release}: {reason}" + "Posit Package Manager can't list system requirements for {distribution} {release}: {r$error}" ) return(NA) } @@ -29,3 +27,14 @@ get_pkg_requirements = function( ~ tibble::tibble(pkg = .x$name, sys_libs = unlist(.x$requirements$packages)) ) } + +# P3M's response as a list. On an HTTP error, `error` holds P3M's reason +read_p3m = function(url) { + res = httr::GET(url, httr::timeout(60)) + r = httr::content(res) + if (!httr::http_error(res)) { + return(r) + } + reason = if (is.list(r) && is.character(r$error)) r$error else httr::http_status(res)$message + list(error = reason) +} diff --git a/R/get_posit_remote_versions.R b/R/get_posit_remote_versions.R index 438da93..f4d6e62 100644 --- a/R/get_posit_remote_versions.R +++ b/R/get_posit_remote_versions.R @@ -8,7 +8,7 @@ get_posit_remote_versions = function(type = c("connect", "workbench")) { } else { "https://docs.posit.co/ide/news/" } - page = rvest::read_html(url) + page = read_posit_news(url) sections = rvest::html_elements(page, "section") v_tibbles = purrr::map_df(sections, extract_posit_cves) all_v = get_all_remote_versions(page) %>% @@ -47,3 +47,7 @@ get_all_remote_versions = function(page) { v = stringr::str_extract(versions, "20[0-9]{2}\\.[0-9]{2}\\.[0-9]{1,2}") tibble::tibble(version = v[!is.na(v)], cve = "") } + +read_posit_news = function(url) { + rvest::read_html(url) +} diff --git a/R/get_uv_python_versions.R b/R/get_uv_python_versions.R index 663d90e..f1593dc 100644 --- a/R/get_uv_python_versions.R +++ b/R/get_uv_python_versions.R @@ -1,6 +1,6 @@ get_uv_python_versions = function() { url = "https://raw.githubusercontent.com/astral-sh/uv/main/crates/uv-python-managed/download-metadata.json" # nolint: line_length_linter - raw_json = jsonlite::fromJSON(url, simplifyVector = FALSE) + raw_json = fetch_json(url) if (length(raw_json) == 0) { warning("Fetched Python JSON is empty -- check the URL/branch/tag.") diff --git a/tests/testthat/fixtures/p3m-ubuntu-24.04.json b/tests/testthat/fixtures/p3m-ubuntu-24.04.json new file mode 100644 index 0000000..c624909 --- /dev/null +++ b/tests/testthat/fixtures/p3m-ubuntu-24.04.json @@ -0,0 +1,41 @@ +{ + "requirements": [ + { + "name": "curl", + "requirements": { + "packages": [ + "libcurl4-openssl-dev", + "libssl-dev" + ] + } + }, + { + "name": "fftw", + "requirements": { + "packages": [ + "libfftw3-dev" + ] + } + }, + { + "name": "sf", + "requirements": { + "packages": [ + "libgdal-dev", + "gdal-bin", + "libgeos-dev", + "libproj-dev", + "libsqlite3-dev" + ] + } + }, + { + "name": "xml2", + "requirements": { + "packages": [ + "libxml2-dev" + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/testthat/fixtures/posit-news.html b/tests/testthat/fixtures/posit-news.html new file mode 100644 index 0000000..3846fab --- /dev/null +++ b/tests/testthat/fixtures/posit-news.html @@ -0,0 +1,33 @@ + + +
+

Introduction

+

This document contains the notes associated with each release of Posit Connect.

+
+
+

2026.10.0

+
+

New

+ +
+
+
+

2025.09.0

+
+

Fixed

+ +
+
+
+

2025.07.0

+

July 2025

+
+ diff --git a/tests/testthat/fixtures/quarto-releases.json b/tests/testthat/fixtures/quarto-releases.json new file mode 100644 index 0000000..255e947 --- /dev/null +++ b/tests/testthat/fixtures/quarto-releases.json @@ -0,0 +1,18 @@ +[ + { + "tag_name": "v1.11.6", + "prerelease": true + }, + { + "tag_name": "v1.10.19", + "prerelease": false + }, + { + "tag_name": "v1.10.18", + "prerelease": false + }, + { + "tag_name": "v1.9.38", + "prerelease": false + } +] \ No newline at end of file diff --git a/tests/testthat/fixtures/r-versions.json b/tests/testthat/fixtures/r-versions.json new file mode 100644 index 0000000..2f60a94 --- /dev/null +++ b/tests/testthat/fixtures/r-versions.json @@ -0,0 +1,10 @@ +{ + "r_versions": [ + "next", + "devel", + "4.6.1", + "4.6.0", + "4.5.3", + "4.5.2" + ] +} \ No newline at end of file diff --git a/tests/testthat/fixtures/uv-metadata.json b/tests/testthat/fixtures/uv-metadata.json new file mode 100644 index 0000000..109fa50 --- /dev/null +++ b/tests/testthat/fixtures/uv-metadata.json @@ -0,0 +1,86 @@ +{ + "cpython-3.16.0a1-linux-x86_64-gnu": { + "name": "cpython", + "arch": { + "family": "x86_64", + "variant": null + }, + "os": "linux", + "libc": "gnu", + "major": 3, + "minor": 16, + "patch": 0, + "prerelease": "a1", + "variant": null + }, + "cpython-3.14.8-linux-x86_64-gnu": { + "name": "cpython", + "arch": { + "family": "x86_64", + "variant": null + }, + "os": "linux", + "libc": "gnu", + "major": 3, + "minor": 14, + "patch": 8, + "prerelease": "", + "variant": null + }, + "cpython-3.14.7-linux-x86_64-gnu": { + "name": "cpython", + "arch": { + "family": "x86_64", + "variant": null + }, + "os": "linux", + "libc": "gnu", + "major": 3, + "minor": 14, + "patch": 7, + "prerelease": "", + "variant": null + }, + "cpython-3.14.8-darwin-aarch64-none": { + "name": "cpython", + "arch": { + "family": "aarch64", + "variant": null + }, + "os": "darwin", + "libc": "none", + "major": 3, + "minor": 14, + "patch": 8, + "prerelease": "", + "variant": null + }, + "cpython-3.14.8-linux-x86_64-musl": { + "name": "cpython", + "arch": { + "family": "x86_64", + "variant": null + }, + "os": "linux", + "libc": "musl", + "major": 3, + "minor": 14, + "patch": 8, + "prerelease": "", + "variant": null + }, + "cpython-3.13.16-linux-x86_64-gnu": { + "name": "cpython", + "arch": { + "family": "x86_64", + "variant": null + }, + "os": "linux", + "libc": "gnu", + "major": 3, + "minor": 13, + "patch": 16, + "prerelease": "", + "variant": null + } +} \ No newline at end of file diff --git a/tests/testthat/helper-fixtures.R b/tests/testthat/helper-fixtures.R new file mode 100644 index 0000000..f76e4c7 --- /dev/null +++ b/tests/testthat/helper-fixtures.R @@ -0,0 +1,31 @@ +# Offline stand-ins for the remote lookups, read from tests/testthat/fixtures +read_fixture_json = function(name) { + jsonlite::read_json(testthat::test_path("fixtures", name)) +} + +# The NEWS page fixture serves both Connect and Workbench +local_posit_news = function(env = parent.frame()) { + testthat::local_mocked_bindings( + read_posit_news = function(url) { + rvest::read_html(testthat::test_path("fixtures", "posit-news.html")) + }, + .env = env + ) +} + +local_version_json = function(env = parent.frame()) { + testthat::local_mocked_bindings( + fetch_json = function(url) { + if (grepl("quarto-cli", url, fixed = TRUE)) { + read_fixture_json("quarto-releases.json") + } else if (grepl("cdn.posit.co/r/", url, fixed = TRUE)) { + read_fixture_json("r-versions.json") + } else if (grepl("download-metadata.json", url, fixed = TRUE)) { + read_fixture_json("uv-metadata.json") + } else { + stop("No fixture for ", url) + } + }, + .env = env + ) +} diff --git a/tests/testthat/test-check_sys_deps.R b/tests/testthat/test-check_sys_deps.R index f89d712..5209052 100644 --- a/tests/testthat/test-check_sys_deps.R +++ b/tests/testthat/test-check_sys_deps.R @@ -1,25 +1,39 @@ -test_that("Testing sys deps", { - testthat::skip_on_ci() - if (Sys.info()["sysname"] != "Linux") { - testthat::skip() - } - os_release = readLines("/etc/os-release") - installed_libs = get_installed_libs() - deps = check_sys_deps(os_release, installed_libs) - expect_equal(colnames(deps), c("pkg", "sys_libs")) - - out = list(sys_deps = deps) - q = get_quarto_sys_deps(out) - expect_equal(colnames(q), c("sys_libs", "pkg", "n")) - expect_true(all(is.integer(q$n))) +test_that("check_sys_deps() lists the packages whose system libraries aren't installed", { + local_mocked_bindings(read_p3m = function(url) { + expect_match(url, "distribution=ubuntu&release=24.04", fixed = TRUE) + read_fixture_json("p3m-ubuntu-24.04.json") + }) + ubuntu = c( + "PRETTY_NAME=\"Ubuntu 24.04.3 LTS\"", + "ID=ubuntu", + "ID_LIKE=debian", + "VERSION_ID=\"24.04\"" + ) + # apt list --installed, after its "Listing..." header + apt = c( + "libssl-dev/noble-updates,noble-security,now 3.0.13-0ubuntu3.6 amd64 [installed]", + "libcurl4-openssl-dev/noble-updates,now 8.5.0-2ubuntu10.6 amd64 [installed]", + "libxml2-dev/noble-updates,now 2.9.14+dfsg-1.3ubuntu3.5 amd64 [installed,automatic]" + ) + installed = clean_libs(ubuntu, apt) + expect_identical(installed, c("libcurl4-openssl-dev", "libssl-dev", "libxml2-dev")) - # Empty state - out = list( - sys_deps = tibble::tibble( - pkg = character(0), - sys_libs = character(0) + missing = suppressMessages(check_sys_deps(ubuntu, installed)) + expected = tibble::tibble( + pkg = c("sf", "fftw", "sf", "sf", "sf", "sf"), + sys_libs = c( + "gdal-bin", + "libfftw3-dev", + "libgdal-dev", + "libgeos-dev", + "libproj-dev", + "libsqlite3-dev" ) ) - q = get_quarto_sys_deps(out) - expect_equal(colnames(q), c("sys_libs", "pkg", "n")) + expect_identical(missing, expected) + + for (sys_deps in list(missing, missing[0, ])) { + q = get_quarto_sys_deps(list(sys_deps = sys_deps)) + expect_equal(colnames(q), c("sys_libs", "pkg", "n")) + } }) diff --git a/tests/testthat/test-get_latest_versions_remote.R b/tests/testthat/test-get_latest_versions_remote.R index de151a3..e4828be 100644 --- a/tests/testthat/test-get_latest_versions_remote.R +++ b/tests/testthat/test-get_latest_versions_remote.R @@ -2,3 +2,28 @@ test_that("latest_patch_per_minor() keeps every minor when a new one ships", { versions = c("1.11.2", "1.10.19", "1.10.3", "1.11.0", "1.9.38", "1.12.0-rc1") expect_identical(latest_patch_per_minor(versions), c("1.11.2", "1.10.19", "1.9.38")) }) + +test_that("get_latest_versions_remote() keeps the latest stable patch of each release", { + local_version_json() + versions = get_latest_versions_remote() + # Quarto 1.0 to 1.6 come from the fixed list; prereleases, musl and macOS builds are dropped + expected = tibble::tibble( + software = rep(c("python", "quarto", "r"), c(2, 9, 2)), + version = c( + "3.14.8", + "3.13.16", + "1.10.19", + "1.9.38", + "1.6.43", + "1.5.57", + "1.4.557", + "1.3.450", + "1.2.475", + "1.1.189", + "1.0.38", + "4.6.1", + "4.5.3" + ) + ) + expect_identical(versions, expected) +}) diff --git a/tests/testthat/test-get_pkg_requirements.R b/tests/testthat/test-get_pkg_requirements.R index b691d03..ccc9100 100644 --- a/tests/testthat/test-get_pkg_requirements.R +++ b/tests/testthat/test-get_pkg_requirements.R @@ -1,9 +1,5 @@ -test_that("Test pkg requirements", { - pkgs = get_pkg_requirements( - distribution = "ubuntu", - release = "22.04" - ) - expect_true(ncol(pkgs) == 2) - expect_true(nrow(pkgs) > 750) - expect_true("fftw" %in% pkgs$pkg) +test_that("P3M's reason for rejecting a system reaches the user", { + local_mocked_bindings(read_p3m = function(url) list(error = "Unsupported system")) + expect_warning(suppressMessages(get_pkg_requirements("rockylinux", "8")), "Unsupported system") + expect_identical(suppressMessages(suppressWarnings(get_pkg_requirements("rockylinux", "8"))), NA) }) diff --git a/tests/testthat/test-os-helper-functions.R b/tests/testthat/test-os-helper-functions.R index 6675b72..f100713 100644 --- a/tests/testthat/test-os-helper-functions.R +++ b/tests/testthat/test-os-helper-functions.R @@ -45,12 +45,6 @@ test_that("Unsupported systems warn and give NA instead of stopping", { expect_equal(colnames(out), c("sys_libs", "pkg", "n")) }) -test_that("P3M's reason for rejecting a system reaches the user", { - skip_if_offline("packagemanager.posit.co") - expect_warning(get_pkg_requirements("rockylinux", "8"), "Unsupported system") - expect_identical(suppressWarnings(get_pkg_requirements("rockylinux", "8")), NA) -}) - test_that("clean_libs() keeps the dots in RHEL package names", { rhel = c("ID=\"rhel\"", "ID_LIKE=\"fedora\"", "VERSION_ID=\"8.10\"") yum = c( diff --git a/tests/testthat/test-posit_versions.R b/tests/testthat/test-posit_versions.R index 96e3496..08b7232 100644 --- a/tests/testthat/test-posit_versions.R +++ b/tests/testthat/test-posit_versions.R @@ -1,43 +1,33 @@ -test_that("Testing check server", { - types = c("workbench", "connect", "drivers") - for (type in types) { - versions = get_posit_versions(type) - expect_equal(ncol(versions), 2) - expect_gt(nrow(versions), 2) - } - type = "connect" - versions = get_posit_versions(type) - # Up to date - latest_version = versions[1, ]$version - - expect_message( - audit_posit_version(latest_version, type), - regexp = "up to date" - ) +test_that("get_posit_versions() reads versions and CVEs from the NEWS page", { + local_posit_news() + versions = get_posit_versions("connect") + expect_identical(unique(versions$version), c("2026.10.0", "2025.09.0", "2025.07.0")) + cves = sort(unique(stats::na.omit(versions$cve))) + expect_identical(cves, c("CVE-2025-47906", "CVE-2025-47907")) - expect_message( - audit_posit_version(paste0(latest_version, ".pro1"), type), - regexp = "up to date" - ) + expect_message(audit_posit_version("2026.10.0", "connect"), "up to date") + expect_message(audit_posit_version("2026.10.0.pro1", "connect"), "up to date") + expect_message(audit_posit_version("2025.07.0", "connect"), "2 newer versions that fix 2 CVEs") +}) - expect_message(audit_posit_version("2022.10.0", type), regexp = "out of date") +test_that("get_posit_versions() falls back to the cache when the NEWS page can't be read", { + local_mocked_bindings(read_posit_news = function(url) stop("HTTP 503")) + for (type in c("connect", "workbench")) { + expect_message(get_posit_versions(type), "HTTP 503.*Falling back to cache") + versions = suppressMessages(get_posit_versions(type)) + expect_identical(versions, get_posit_versions(type, remote = FALSE)) + expect_gt(nrow(versions), 2) + } + expect_gt(nrow(get_posit_versions("drivers")), 2) }) -test_that("Testing check Posit versions", { - type = "connect" - remote = get_posit_remote_versions(type) - local = get_posit_versions(type, remote = FALSE) - expect_true( - all(remote$version %in% local$version), - info = "Try running update_all_versions()" - ) - type = "workbench" - remote = get_posit_remote_versions(type) - local = get_posit_versions(type, remote = FALSE) - expect_true( - all(remote$version %in% local$version), - info = "Try running update_all_versions()" - ) +test_that("The cached Posit versions are up to date", { + skip_if_offline("docs.posit.co") + for (type in c("connect", "workbench")) { + remote = get_posit_remote_versions(type) + local = get_posit_versions(type, remote = FALSE) + expect_true(all(remote$version %in% local$version), info = "Try running update_all_versions()") + } }) test_that("get_posit_versions() keeps CVE IDs and marks versions without one as NA", { diff --git a/tests/testthat/test-quarto-helpers.R b/tests/testthat/test-quarto-helpers.R index f30ec35..b8b8c9a 100644 --- a/tests/testthat/test-quarto-helpers.R +++ b/tests/testthat/test-quarto-helpers.R @@ -1,17 +1,21 @@ -test_that("Quarto Outputs", { - out = list() - out$posit_version = "2022.10.0" - msg = get_quarto_posit_version_msg(out, type = "connect") - expect_true(stringr::str_detect(msg, "CVEs")) - - out$posit_version = "2012.10.0" - msg = get_quarto_posit_version_msg(out, "connect") - expect_true(stringr::str_detect(msg, "in our database")) - - out$posit_version = get_posit_versions("connect")[1, ]$version - msg = get_quarto_posit_version_msg(out, "connect") - expect_true(stringr::str_detect(msg, "is up to date")) +test_that("The Posit version message and status agree", { + local_posit_news() + cases = tibble::tribble( + ~posit_version, ~status, ~msg, + "2026.10.0", "current", "is up to date", + "2025.07.0", "outdated", "fix 2 CVEs", + "2012.10.0", "unknown", "in our database" + ) + for (i in seq_len(nrow(cases))) { + out = list(posit_version = cases$posit_version[i]) + msg = get_quarto_posit_version_msg(out, "connect") + expect_match(msg, cases$msg[i], label = cases$posit_version[i]) + expect_identical(get_quarto_posit_version_status(out, "connect"), cases$status[i]) + } +}) +test_that("Server headers are tabulated for the report", { + skip_if_offline("google.com") out = list(server_headers = serverHeaders::check("google.com")) q = get_quarto_server_header(out) expect_equal(colnames(q), c("header", "status", "message", "value")) @@ -19,17 +23,6 @@ test_that("Quarto Outputs", { expect_true(nrow(q) >= 6) }) -test_that("Posit version status matches the message", { - out = list(posit_version = "2022.10.0") - expect_equal(get_quarto_posit_version_status(out, "connect"), "outdated") - - out$posit_version = "2012.10.0" - expect_equal(get_quarto_posit_version_status(out, "connect"), "unknown") - - out$posit_version = get_posit_versions("connect")[1, ]$version - expect_equal(get_quarto_posit_version_status(out, "connect"), "current") -}) - test_that("version_status maps row numbers", { expect_equal(version_status(NA_integer_), "unknown") expect_equal(version_status(1L), "current") diff --git a/tests/testthat/test-software-versions.R b/tests/testthat/test-software-versions.R index 8fd0f3a..9682046 100644 --- a/tests/testthat/test-software-versions.R +++ b/tests/testthat/test-software-versions.R @@ -76,6 +76,7 @@ test_that("Testing augment_installed (future))", { test_that("Ensure that software versions are up to date", { testthat::skip_on_ci() + skip_if_offline("cdn.posit.co") versions = get_latest_versions(remote = FALSE) latest = get_latest_versions_remote()