asa-api / Tests /api_contract_smoke.R
cjerzak's picture
add files
1307edc
suppressPackageStartupMessages({
library(asa)
})
auth_fixture <- c(
ASA_API_BEARER_TOKEN = "test-bearer-secret",
GUI_PASSWORD = "test-gui-secret"
)
tor_fixture_names <- c(
"ASA_ENABLE_TOR",
"ASA_PROXY",
"TOR_CONTROL_PORT",
"ASA_TOR_CONTROL_COOKIE"
)
managed_env_names <- c(names(auth_fixture), tor_fixture_names)
previous_managed_env <- Sys.getenv(managed_env_names, unset = NA_character_)
restore_managed_env <- function() {
for (name in names(previous_managed_env)) {
value <- previous_managed_env[[name]]
if (is.na(value)) {
Sys.unsetenv(name)
} else {
do.call(Sys.setenv, stats::setNames(list(value), name))
}
}
}
on.exit({
restore_managed_env()
if (exists("asa_api_clear_auth_cache", mode = "function")) {
asa_api_clear_auth_cache()
}
}, add = TRUE)
do.call(Sys.setenv, as.list(auth_fixture))
args <- commandArgs(trailingOnly = FALSE)
file_arg <- "--file="
script_arg <- args[grepl(paste0("^", file_arg), args)]
script_path <- if (length(script_arg)) {
normalizePath(sub(file_arg, "", script_arg[[1]]), mustWork = TRUE)
} else {
normalizePath("Tests/api_contract_smoke.R", mustWork = TRUE)
}
repo_root <- normalizePath(file.path(dirname(script_path), ".."), mustWork = TRUE)
source(file.path(repo_root, "R", "asa_api_helpers.R"))
asa_api_refresh_auth_cache(force = TRUE)
assert_true <- function(condition, message) {
if (!isTRUE(condition)) {
stop(message, call. = FALSE)
}
}
expect_error_contains <- function(expr, pattern) {
error_message <- NULL
tryCatch(
force(expr),
error = function(e) {
error_message <<- conditionMessage(e)
NULL
}
)
if (is.null(error_message)) {
stop(sprintf("Expected error containing %s, but no error was raised.", sQuote(pattern)), call. = FALSE)
}
if (!grepl(pattern, error_message, fixed = TRUE)) {
stop(
sprintf(
"Expected error containing %s, got: %s",
sQuote(pattern),
error_message
),
call. = FALSE
)
}
invisible(error_message)
}
prompts <- asa_api_require_prompts(list(prompts = list(" Hello ", "World ")))
assert_true(identical(prompts, c("Hello", "World")), "Valid prompt arrays should be trimmed and preserved.")
expect_error_contains(
asa_api_require_prompts(list(prompts = list(list(prompt = "Q1", id = "row1")))),
"Structured prompt objects are not supported by `/v1/batch`."
)
expect_error_contains(
asa_api_require_prompts(list(prompts = list("Hello", TRUE))),
"`prompts` must be a JSON array of non-empty strings."
)
expect_error_contains(
asa_api_require_prompts(list(prompts = list("Hello", " "))),
"Each entry in `prompts` must be a non-empty string."
)
asa_api_validate_batch_supported_fields(list(
prompts = list("Hello", "World"),
run = list(output_format = "text", performance_profile = "balanced")
))
expect_error_contains(
asa_api_validate_batch_supported_fields(list(
prompts = list("Hello"),
run = list(expected_schema = list(type = "object"))
)),
"Unsupported `/v1/batch` `run` keys:"
)
expect_error_contains(
asa_api_validate_batch_supported_fields(list(
prompts = list("Hello"),
use_plan_mode = TRUE
)),
"Unsupported `/v1/batch` top-level keys:"
)
run_args <- asa_api_build_run_args(list(run = list(
expected_schema = list(type = "object"),
use_plan_mode = TRUE,
performance_profile = "balanced"
)))
assert_true(
all(c("output_format", "expected_schema", "use_plan_mode", "performance_profile") %in% names(run_args)),
"`/v1/run` should continue forwarding newer upstream run_task options."
)
batch_args <- asa_api_build_batch_args(list(
run = list(output_format = "json", performance_profile = "quality"),
parallel = FALSE
))
assert_true(
all(c("output_format", "performance_profile", "parallel", "progress") %in% names(batch_args)),
"Batch-compatible shared options should still flow into run_task_batch."
)
assert_true(
identical(asa_api_has_run_direct_task(), !is.null(asa_api_get_run_direct_task(optional = TRUE))),
"Direct-provider capability checks should agree on run_direct_task availability."
)
mock_request <- function(path = "/v1/run", authorization = NULL, x_api_key = NULL) {
headers <- list()
if (!is.null(authorization)) {
headers$authorization <- authorization
}
if (!is.null(x_api_key)) {
headers[["x-api-key"]] <- x_api_key
}
list(
PATH_INFO = path,
HEADERS = headers
)
}
assert_true(
isTRUE(asa_api_path_requires_bearer_auth("/v1/run")) &&
isTRUE(asa_api_path_requires_bearer_auth("/v1/batch")),
"`/v1/*` routes should require bearer auth."
)
assert_true(
!isTRUE(asa_api_path_requires_bearer_auth("/healthz")) &&
!isTRUE(asa_api_path_requires_bearer_auth("/gui/query")),
"Health and GUI routes should remain outside bearer auth scope."
)
assert_true(
identical(asa_api_missing_auth_env_vars(), character(0)),
"Auth bootstrap should require explicit bearer-token and GUI-password env vars."
)
auth_config_boot_failure <- asa_api_boot_failure(
"Missing required authentication environment variable(s): `GUI_PASSWORD`, `ASA_API_BEARER_TOKEN`."
)
assert_true(
identical(auth_config_boot_failure$status_code, 503L) &&
identical(auth_config_boot_failure$error_code, "auth_config_missing") &&
identical(
sort(auth_config_boot_failure$details$missing_env_vars),
sort(c("GUI_PASSWORD", "ASA_API_BEARER_TOKEN"))
),
"Boot failures caused by missing auth env vars should expose a structured safe error."
)
generic_boot_failure <- asa_api_boot_failure("Package `asa` is not installed in this environment.")
assert_true(
identical(generic_boot_failure$status_code, 503L) &&
identical(generic_boot_failure$message, "Service unavailable.") &&
is.null(generic_boot_failure$error_code),
"Non-auth boot failures should remain generic in request responses."
)
auth_config_payload <- asa_api_error_fields(
auth_config_boot_failure$message,
auth_config_boot_failure$error_code,
auth_config_boot_failure$details
)
assert_true(
identical(auth_config_payload$error_code, "auth_config_missing") &&
identical(
sort(auth_config_payload$details$missing_env_vars),
sort(c("GUI_PASSWORD", "ASA_API_BEARER_TOKEN"))
),
"Structured auth-config failures should render with error_code and missing_env_vars details."
)
assert_true(
is.character(.asa_api_auth_cache$api_bearer_token_hash) &&
nzchar(.asa_api_auth_cache$api_bearer_token_hash) &&
!identical(.asa_api_auth_cache$api_bearer_token_hash, auth_fixture[["ASA_API_BEARER_TOKEN"]]),
"Bearer auth should store a derived hash rather than the raw bearer token."
)
assert_true(
identical(
asa_api_extract_bearer_token(mock_request(authorization = sprintf("Bearer %s", auth_fixture[["ASA_API_BEARER_TOKEN"]]))),
auth_fixture[["ASA_API_BEARER_TOKEN"]]
),
"Bearer extraction should accept the required token."
)
assert_true(
identical(
asa_api_extract_bearer_token(list(
PATH_INFO = "/v1/run",
HEADERS = list(Authorization = sprintf("Bearer %s", auth_fixture[["ASA_API_BEARER_TOKEN"]]))
)),
auth_fixture[["ASA_API_BEARER_TOKEN"]]
),
"Bearer extraction should match Authorization headers case-insensitively."
)
assert_true(
identical(
asa_api_extract_bearer_token(mock_request(authorization = sprintf("Basic %s", auth_fixture[["ASA_API_BEARER_TOKEN"]]))),
""
),
"Non-bearer Authorization schemes should not be accepted."
)
assert_true(
isTRUE(asa_api_has_required_bearer_token(mock_request(
authorization = sprintf("Bearer %s", auth_fixture[["ASA_API_BEARER_TOKEN"]])
))),
"Bearer auth should accept the configured Authorization header."
)
assert_true(
!isTRUE(asa_api_has_required_bearer_token(mock_request(authorization = "Bearer wrong"))),
"Bearer auth should reject the wrong token."
)
assert_true(
!isTRUE(asa_api_has_required_bearer_token(mock_request(x_api_key = auth_fixture[["ASA_API_BEARER_TOKEN"]]))),
"Legacy x-api-key auth should no longer be accepted."
)
assert_true(
isTRUE(asa_api_has_required_gui_password(auth_fixture[["GUI_PASSWORD"]])),
"GUI auth should accept the configured password."
)
gui_mismatch <- asa_api_check_gui_password("wrong-password")
assert_true(
identical(gui_mismatch$ok, FALSE) &&
identical(gui_mismatch$status_code, 401L) &&
identical(gui_mismatch$error_code, "credential_mismatch") &&
identical(gui_mismatch$details$auth_target, "gui_password"),
"GUI auth should report credential mismatches with a structured safe error."
)
gui_mismatch_payload <- asa_api_error_fields(
gui_mismatch$message,
gui_mismatch$error_code,
gui_mismatch$details
)
assert_true(
identical(
gui_mismatch_payload$error,
"Unauthorized: provided credential did not match the configured value."
) &&
identical(gui_mismatch_payload$error_code, "credential_mismatch") &&
identical(gui_mismatch_payload$details$auth_target, "gui_password"),
"GUI credential mismatches should render the expected response fields."
)
api_mismatch <- asa_api_check_bearer_token(mock_request(authorization = "Bearer wrong"))
assert_true(
identical(api_mismatch$ok, FALSE) &&
identical(api_mismatch$status_code, 401L) &&
identical(api_mismatch$error_code, "credential_mismatch") &&
identical(api_mismatch$details$auth_target, "api_bearer_token"),
"API auth should report bearer-token mismatches with a structured safe error."
)
asa_api_clear_auth_cache()
Sys.unsetenv("ASA_API_BEARER_TOKEN")
expect_error_contains(
asa_api_refresh_auth_cache(force = TRUE),
"ASA_API_BEARER_TOKEN"
)
gui_missing_auth_config <- asa_api_check_gui_password("anything")
assert_true(
identical(gui_missing_auth_config$ok, FALSE) &&
identical(gui_missing_auth_config$status_code, 503L) &&
identical(gui_missing_auth_config$error_code, "auth_config_missing") &&
identical(gui_missing_auth_config$details$missing_env_vars, "ASA_API_BEARER_TOKEN"),
"Missing auth env vars should surface as structured startup misconfiguration errors."
)
do.call(Sys.setenv, stats::setNames(list(auth_fixture[["ASA_API_BEARER_TOKEN"]]), "ASA_API_BEARER_TOKEN"))
asa_api_clear_auth_cache()
Sys.unsetenv("GUI_PASSWORD")
expect_error_contains(
asa_api_refresh_auth_cache(force = TRUE),
"GUI_PASSWORD"
)
do.call(Sys.setenv, stats::setNames(list(auth_fixture[["GUI_PASSWORD"]]), "GUI_PASSWORD"))
asa_api_refresh_auth_cache(force = TRUE)
source(file.path(repo_root, "R", "plumber.R"))
res_env <- new.env(parent = emptyenv())
rendered_payload <- asa_api_error_payload(
res_env,
gui_mismatch$status_code,
gui_mismatch$message,
gui_mismatch$error_code,
gui_mismatch$details
)
assert_true(
identical(res_env$status, 401L) &&
identical(rendered_payload$error_code, "credential_mismatch") &&
identical(rendered_payload$details$auth_target, "gui_password"),
"Route error rendering should preserve structured auth diagnostics."
)
assert_true(
identical(asa_api_runtime_error_code("asa_agent_batch"), "agent_batch_failure") &&
identical(asa_api_runtime_error_code("provider_direct_single"), "direct_provider_failure") &&
identical(asa_api_runtime_error_code("asa_agent_single"), "agent_pipeline_failure"),
"Runtime failures should map to stable mode-specific error codes."
)
assert_true(
identical(
asa_api_error_status("Unsupported `/v1/batch` `run` keys: `expected_schema`."),
400L
),
"Unsupported adapter inputs should continue to classify as client errors."
)
diagnostic_prompt <- paste(rep("diagnostic prompt segment", 16), collapse = " ")
expected_excerpt <- asa_api_redact_text_excerpt(diagnostic_prompt)
diagnostic_log_lines <- character(0)
diagnostic_log_con <- textConnection("diagnostic_log_lines", "w", local = TRUE)
runtime_error <- NULL
tryCatch(
asa_api_invoke_with_runtime_diagnostics(
fn = function() {
stop("subscript out of bounds", call. = FALSE)
},
mode = "asa_agent_single",
route = "/v1/run",
payload = list(
include_raw_output = TRUE,
include_trace_json = FALSE
),
prompt = diagnostic_prompt,
config = list(
backend = "gemini",
model = "gemini-2.5-pro",
conda_env = "asa_env",
use_browser = FALSE
),
forwarded_arg_names = c("output_format", "performance_profile"),
request_shape = list(
output_format = "json",
performance_profile = "quality"
),
stage_ref = "invoke",
log_con = diagnostic_log_con
),
error = function(e) {
runtime_error <<- e
NULL
}
)
close(diagnostic_log_con)
assert_true(
inherits(runtime_error, "asa_api_runtime_error") &&
identical(runtime_error$error_code, "agent_pipeline_failure"),
"Runtime diagnostics should rethrow a structured asa_api_runtime_error."
)
assert_true(
identical(runtime_error$details$route, "/v1/run") &&
identical(runtime_error$details$stage, "invoke") &&
is.null(runtime_error$details$request) &&
identical(runtime_error$details_full$request$output_format, "json") &&
identical(runtime_error$details_full$request$include_raw_output, TRUE),
"Runtime errors should expose only the safe summary in `details` while retaining full internals separately."
)
runtime_failure <- asa_api_error_failure(runtime_error)
assert_true(
identical(runtime_failure$status_code, 500L) &&
identical(runtime_failure$error_code, "agent_pipeline_failure") &&
identical(runtime_failure$details$route, "/v1/run") &&
identical(runtime_failure$details$stage, "invoke") &&
is.null(runtime_failure$details$request) &&
nzchar(runtime_failure$details$diagnostic_id),
"Structured runtime failures should preserve diagnostic metadata for API responses."
)
diagnostic_log_text <- paste(diagnostic_log_lines, collapse = "\n")
assert_true(
grepl("diagnostic_id=", diagnostic_log_text, fixed = TRUE) &&
grepl("subscript out of bounds", diagnostic_log_text, fixed = TRUE),
"Runtime diagnostics should log the failure message with a correlation id."
)
assert_true(
grepl(expected_excerpt, diagnostic_log_text, fixed = TRUE) &&
!grepl(diagnostic_prompt, diagnostic_log_text, fixed = TRUE),
"Runtime diagnostics should log only a redacted prompt excerpt, not the full prompt."
)
runtime_res_env <- new.env(parent = emptyenv())
runtime_payload <- asa_api_failure_payload(runtime_res_env, runtime_failure)
assert_true(
identical(runtime_res_env$status, 500L) &&
identical(runtime_payload$error_code, "agent_pipeline_failure") &&
identical(runtime_payload$details$diagnostic_id, runtime_failure$details$diagnostic_id),
"Route error rendering should preserve structured runtime diagnostics."
)
{
original_build_config <- asa_api_build_config
build_config_log_lines <- character(0)
build_config_log_con <- textConnection("build_config_log_lines", "w", local = TRUE)
build_config_error <- NULL
asa_api_build_config <- function(payload) {
stop("subscript out of bounds", call. = FALSE)
}
tryCatch(
asa_api_run_single_via_asa(
list(prompt = "Hello", run = list(output_format = "json")),
request_context = list(route = "/v1/run", log_con = build_config_log_con)
),
error = function(e) {
build_config_error <<- e
NULL
}
)
close(build_config_log_con)
asa_api_build_config <- original_build_config
build_config_failure <- asa_api_error_failure(build_config_error)
build_config_log_text <- paste(build_config_log_lines, collapse = "\n")
assert_true(
inherits(build_config_error, "asa_api_runtime_error") &&
identical(build_config_failure$error_code, "agent_pipeline_failure") &&
identical(build_config_failure$details$stage, "build_config") &&
grepl("stage=build_config", build_config_log_text, fixed = TRUE),
"Pre-invocation internal failures should still emit structured runtime diagnostics."
)
}
{
original_run_single <- asa_api_run_single
original_project_gui_result <- asa_api_project_gui_result
gui_log_lines <- character(0)
gui_log_con <- textConnection("gui_log_lines", "w", local = TRUE)
gui_error <- NULL
asa_api_run_single <- function(payload, allow_direct_provider = FALSE, request_context = NULL) {
list(
status = "success",
message = "ok",
execution = list(mode = "asa_agent")
)
}
asa_api_project_gui_result <- function(result, response_mode = NULL) {
stop("subscript out of bounds", call. = FALSE)
}
tryCatch(
asa_api_run_gui_query(
list(prompt = "Hello", run = list(output_format = "json")),
request_context = list(route = "/gui/query", log_con = gui_log_con)
),
error = function(e) {
gui_error <<- e
NULL
}
)
close(gui_log_con)
asa_api_run_single <- original_run_single
asa_api_project_gui_result <- original_project_gui_result
gui_failure <- asa_api_error_failure(gui_error)
gui_res_env <- new.env(parent = emptyenv())
gui_payload <- asa_api_failure_payload(gui_res_env, gui_failure)
gui_log_text <- paste(gui_log_lines, collapse = "\n")
assert_true(
inherits(gui_error, "asa_api_runtime_error") &&
identical(gui_failure$details$route, "/gui/query") &&
identical(gui_failure$details$stage, "project_gui_result") &&
identical(gui_payload$details$stage, "project_gui_result") &&
grepl("stage=project_gui_result", gui_log_text, fixed = TRUE),
"GUI projection failures should return the safe diagnostic summary and emit matching logs."
)
}
health_payload <- asa_api_health_payload()
assert_true(
identical(isTRUE(health_payload$direct_provider_available), isTRUE(asa_api_has_run_direct_task())),
"Health payload should report direct-provider availability from the same capability check."
)
if (isTRUE(health_payload$direct_provider_available)) {
assert_true(
is.null(health_payload$direct_provider_note),
"Health payload should omit the direct-provider note when the capability is available."
)
} else {
assert_true(
is.character(health_payload$direct_provider_note) && nzchar(trimws(health_payload$direct_provider_note)),
"Health payload should explain why direct-provider mode is unavailable."
)
}
Sys.unsetenv(tor_fixture_names)
tor_health_disabled <- asa_api_tor_health()
assert_true(
identical(tor_health_disabled$tor_enabled, FALSE) &&
identical(tor_health_disabled$tor_ready, FALSE),
"Tor health should report disabled when the proxy env vars are absent."
)
assert_true(
is.null(tor_health_disabled$tor_proxy) &&
is.null(tor_health_disabled$tor_control_port),
"Tor health should omit proxy details when Tor is disabled."
)
health_without_tor <- asa_api_health_payload()
assert_true(
identical(health_without_tor$status, "ok"),
"Service health should remain ok when Tor is intentionally disabled."
)
do.call(Sys.setenv, as.list(auth_fixture))
asa_api_refresh_auth_cache(force = TRUE)
{
original_asa <- asa_api_run_single_via_asa
original_direct <- asa_api_run_single_via_direct
dispatch_calls <- character(0)
asa_api_run_single_via_asa <- function(payload, request_context = NULL) {
dispatch_calls <<- c(dispatch_calls, "asa")
list(status = "success", execution = list(mode = "asa_agent"))
}
asa_api_run_single_via_direct <- function(payload, request_context = NULL) {
dispatch_calls <<- c(dispatch_calls, "direct")
list(status = "success", execution = list(mode = "provider_direct"))
}
asa_api_run_single(
list(prompt = "Hello", use_direct_provider = TRUE),
allow_direct_provider = FALSE
)
assert_true(
identical(dispatch_calls, "asa"),
"Single-run API should ignore the private direct-provider flag."
)
dispatch_calls <- character(0)
gui_direct <- asa_api_run_single(
list(prompt = "Hello", use_direct_provider = TRUE),
allow_direct_provider = TRUE
)
assert_true(
identical(dispatch_calls, "direct"),
"GUI single-run path should dispatch to direct-provider mode when enabled."
)
assert_true(
identical(gui_direct$execution$mode, "provider_direct"),
"Direct-provider dispatch should preserve provider_direct mode metadata."
)
asa_api_run_single_via_asa <- original_asa
asa_api_run_single_via_direct <- original_direct
}
gui_result <- asa_api_project_gui_result(list(
status = "success",
message = "ok",
parsed = list(capital = "Paris"),
elapsed_time_min = 0.25,
search_tier = "unknown",
parsing_status = list(valid = TRUE),
execution = list(
thread_id = list("asa_123"),
backend_status = list("success"),
status_code = list(200L),
tool_calls_used = list(2L),
search_calls_used = list(1L),
action_step_count = list(6L),
tool_quality_events = list(list(
message_index_in_round = list(1L),
tool_name = list("update_plan"),
is_empty = list(FALSE),
is_off_target = list(FALSE),
is_error = list(FALSE),
error_type = NULL,
elapsed_ms_estimate = list(0L),
quality_version = list("v1")
)),
config_snapshot = list(backend = "gemini", model = "gemini-3-flash-preview")
)
))
assert_true(
identical(
names(gui_result$execution),
c(
"mode",
"thread_id",
"backend_status",
"status_code",
"tool_calls_used",
"search_calls_used",
"action_step_count"
)
) &&
identical(gui_result$execution$mode, "asa_agent") &&
identical(gui_result$execution$thread_id, "asa_123") &&
identical(gui_result$execution$tool_calls_used, 2L),
"GUI success responses should expose only a compact execution summary."
)
assert_true(
!("tool_quality_events" %in% names(gui_result$execution)) &&
!("config_snapshot" %in% names(gui_result$execution)),
"GUI projection should avoid recursing through nested execution diagnostics."
)
gui_direct_result <- asa_api_project_gui_result(
list(
status = "success",
execution = list(
mode = list("provider_direct"),
status_code = list(200L)
)
),
response_mode = "provider_direct_single"
)
assert_true(
identical(gui_direct_result$execution$mode, "provider_direct") &&
identical(gui_direct_result$execution$status_code, 200L),
"GUI projection should preserve direct-provider mode in the compact execution summary."
)
cat("asa-api contract smoke checks passed\n")