diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a8207a4..d36851c 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -2,3 +2,8 @@ **Vulnerability:** Unvalidated inputs passed to `if()` statements can cause process crashes (`condition has length > 1`) or unexpected coercion vulnerabilities. **Learning:** In R, optional boolean parameters that default to `NULL` should be validated using explicit runtime type validation (e.g., `if (!is.null(flag) && (!is.logical(flag) || length(flag) != 1 || is.na(flag)))`). **Prevention:** Always implement explicit runtime type validation for optional boolean parameters. + +## 2026-08-13 - Bound interactive binary choices before integer coercion +**Vulnerability:** A menu prompt accepted any digit string with `^[0-9]+$`; an out-of-range integer could coerce to `NA_integer_` and abort later condition evaluation. +**Learning:** Validate menu input against the documented value set before coercion. +**Prevention:** Use an exact bounded expression such as `^[12]$` for binary choices and retain a bounded retry limit. diff --git a/DESCRIPTION b/DESCRIPTION index f31d3e1..63b2ee7 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -10,7 +10,7 @@ Description: Automates fixed item parameter linking for test linking under the item response theory paradigm using mirt package estimates. License: GPL-3 | file LICENSE Imports: mirt, methods -Suggests: testthat (>= 3.0.0) +Suggests: testthat (>= 3.2.0) Encoding: UTF-8 Config/testthat/edition: 3 Config/roxygen2/version: 8.0.0 diff --git a/R/aFIPC.R b/R/aFIPC.R index 6254651..918e19b 100644 --- a/R/aFIPC.R +++ b/R/aFIPC.R @@ -141,7 +141,7 @@ autoFIPC <- } for (attempt in seq_len(3)) { n <- readline(prompt = "Is it correct? (1: Yes 2: No) : ") - if (grepl("^[0-9]+$", n)) { + if (grepl("^[12]$", n)) { return(as.integer(n)) } } @@ -171,7 +171,7 @@ autoFIPC <- readline( prompt = "Do you want to use default BILOG-MG priors for oldform Data? (1: Yes 2: No) : " ) - if (grepl("^[0-9]+$", n)) { + if (grepl("^[12]$", n)) { return(as.integer(n)) } } @@ -390,7 +390,7 @@ autoFIPC <- readline( prompt = "Do you want to use default BILOG-MG priors for newform Data? (1: Yes 2: No) : " ) - if (grepl("^[0-9]+$", n)) { + if (grepl("^[12]$", n)) { return(as.integer(n)) } } diff --git a/R/mock-bindings.R b/R/mock-bindings.R new file mode 100644 index 0000000..cca365f --- /dev/null +++ b/R/mock-bindings.R @@ -0,0 +1,8 @@ +# Internal namespace bindings for deterministic tests of interactive code. +# +# Function lookup skips these NULL values during normal execution and continues +# to the corresponding base functions. testthat can temporarily replace the +# pre-existing namespace bindings during R CMD check, where the namespace is +# locked and new bindings cannot be created. +interactive <- NULL +readline <- NULL diff --git a/tests/testthat/test-sentinel-input-validation.R b/tests/testthat/test-sentinel-input-validation.R new file mode 100644 index 0000000..b06e403 --- /dev/null +++ b/tests/testthat/test-sentinel-input-validation.R @@ -0,0 +1,106 @@ +binary_prompt_data <- function(row_count = 40L) { + set.seed(20260813) + data.frame( + item_1 = stats::rbinom(row_count, 1L, 0.45), + item_2 = stats::rbinom(row_count, 1L, 0.55), + item_3 = stats::rbinom(row_count, 1L, 0.50), + item_4 = stats::rbinom(row_count, 1L, 0.60) + ) +} + +test_that("common-item confirmation rejects oversized integer input after three attempts", { + testthat::local_mocked_bindings( + interactive = function() TRUE, + readline = testthat::mock_output_sequence( + "999999999999999999999", + "999999999999999999999", + "999999999999999999999" + ), + .package = "aFIPC" + ) + + response_data <- binary_prompt_data() + expect_error( + suppressMessages(aFIPC::autoFIPC( + newformXData = response_data, + oldformYData = response_data, + newformCommonItemNames = c("item_1", "item_2"), + oldformCommonItemNames = c("item_1", "item_2"), + itemtype = "2PL" + )), + "Too many invalid common item confirmation attempts", + fixed = TRUE + ) +}) + +test_that("old-form BILOG prompt rejects values outside the documented binary choices", { + testthat::local_mocked_bindings( + interactive = function() TRUE, + readline = testthat::mock_output_sequence( + "1", + "0", + "3", + "999999999999999999999" + ), + .package = "aFIPC" + ) + + response_data <- binary_prompt_data() + expect_error( + suppressMessages(aFIPC::autoFIPC( + newformXData = response_data, + oldformYData = response_data, + newformCommonItemNames = c("item_1", "item_2"), + oldformCommonItemNames = c("item_1", "item_2"), + itemtype = "3PL" + )), + "Too many invalid oldform BILOG prior attempts", + fixed = TRUE + ) +}) + +test_that("new-form BILOG prompt applies the same bounded validation contract", { + skip_if_not_installed("mirt") + + set.seed(20260814) + item_names <- paste0("item_", 1:4) + old_data <- as.data.frame(mirt::simdata( + a = matrix(c(0.9, 1.1, 0.8, 1.2), ncol = 1L), + d = c(-0.5, 0.0, 0.5, 0.8), + itemtype = rep("2PL", 4L), + N = 250L + )) + names(old_data) <- item_names + old_model <- mirt::mirt( + old_data, + 1L, + itemtype = "2PL", + SE = FALSE, + verbose = FALSE, + technical = list(NCYCLES = 300L) + ) + + testthat::local_mocked_bindings( + interactive = function() TRUE, + readline = testthat::mock_output_sequence( + "1", + "0", + "3", + "999999999999999999999" + ), + .package = "aFIPC" + ) + + new_data <- binary_prompt_data(row_count = 250L) + expect_error( + suppressMessages(aFIPC::autoFIPC( + newformXData = new_data, + oldformYData = old_model, + newformCommonItemNames = c("item_1", "item_2"), + oldformCommonItemNames = c("item_1", "item_2"), + itemtype = "3PL" + )), + "Too many invalid newform BILOG prior attempts", + fixed = TRUE + ) +})