add files
Browse files- R/asa_api_helpers.R +117 -13
- R/plumber.R +34 -14
- README.md +3 -0
- Tests/api_contract_smoke.R +84 -2
R/asa_api_helpers.R
CHANGED
|
@@ -300,11 +300,74 @@ asa_api_secret_env_value <- function(name) {
|
|
| 300 |
trimws(Sys.getenv(asa_api_scalar_chr(name, default = ""), unset = ""))
|
| 301 |
}
|
| 302 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
asa_api_missing_auth_env_vars <- function() {
|
| 304 |
required <- c("ASA_API_BEARER_TOKEN", "GUI_PASSWORD")
|
| 305 |
required[!nzchar(vapply(required, asa_api_secret_env_value, character(1)))]
|
| 306 |
}
|
| 307 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
asa_api_refresh_auth_cache <- function(force = FALSE) {
|
| 309 |
cached_api_hash <- .asa_api_auth_cache$api_bearer_token_hash %||% ""
|
| 310 |
cached_gui_hash <- .asa_api_auth_cache$gui_password_hash %||% ""
|
|
@@ -336,21 +399,49 @@ asa_api_refresh_auth_cache <- function(force = FALSE) {
|
|
| 336 |
invisible(TRUE)
|
| 337 |
}
|
| 338 |
|
| 339 |
-
|
| 340 |
supplied <- asa_api_scalar_chr(candidate, default = "")
|
| 341 |
-
|
| 342 |
-
return(FALSE)
|
| 343 |
-
}
|
| 344 |
|
| 345 |
-
tryCatch(
|
| 346 |
{
|
| 347 |
asa_api_refresh_auth_cache()
|
| 348 |
stored_hash <- .asa_api_auth_cache[[asa_api_scalar_chr(cache_key, default = "")]] %||% ""
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
},
|
| 353 |
-
error = function(e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
)
|
| 355 |
}
|
| 356 |
|
|
@@ -368,15 +459,28 @@ asa_api_extract_bearer_token <- function(req) {
|
|
| 368 |
""
|
| 369 |
}
|
| 370 |
|
| 371 |
-
|
| 372 |
-
|
| 373 |
asa_api_extract_bearer_token(req),
|
| 374 |
-
"api_bearer_token_hash"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
)
|
| 376 |
}
|
| 377 |
|
| 378 |
asa_api_has_required_gui_password <- function(password) {
|
| 379 |
-
|
| 380 |
}
|
| 381 |
|
| 382 |
asa_api_require_prompt <- function(payload) {
|
|
|
|
| 300 |
trimws(Sys.getenv(asa_api_scalar_chr(name, default = ""), unset = ""))
|
| 301 |
}
|
| 302 |
|
| 303 |
+
asa_api_error_fields <- function(message, error_code = NULL, details = NULL) {
|
| 304 |
+
payload <- list(
|
| 305 |
+
status = "error",
|
| 306 |
+
error = asa_api_scalar_chr(message, default = "Request failed.")
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
code <- asa_api_scalar_chr(error_code, default = "")
|
| 310 |
+
if (nzchar(code)) {
|
| 311 |
+
payload$error_code <- code
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
if (is.list(details) && length(details)) {
|
| 315 |
+
payload$details <- details
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
payload
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
asa_api_missing_auth_env_vars <- function() {
|
| 322 |
required <- c("ASA_API_BEARER_TOKEN", "GUI_PASSWORD")
|
| 323 |
required[!nzchar(vapply(required, asa_api_secret_env_value, character(1)))]
|
| 324 |
}
|
| 325 |
|
| 326 |
+
asa_api_extract_missing_auth_env_vars <- function(message = NULL) {
|
| 327 |
+
text <- asa_api_scalar_chr(message, default = "")
|
| 328 |
+
matches <- regmatches(
|
| 329 |
+
text,
|
| 330 |
+
gregexpr("`[^`]+`", text, perl = TRUE)
|
| 331 |
+
)[[1]]
|
| 332 |
+
parsed <- if (length(matches) && !identical(matches, character(0))) {
|
| 333 |
+
gsub("^`|`$", "", matches)
|
| 334 |
+
} else {
|
| 335 |
+
character(0)
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
sort(unique(c(parsed, asa_api_missing_auth_env_vars())))
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
asa_api_is_auth_config_error <- function(message = NULL) {
|
| 342 |
+
grepl(
|
| 343 |
+
"^Missing required authentication environment variable\\(s\\):",
|
| 344 |
+
asa_api_scalar_chr(message, default = "")
|
| 345 |
+
)
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
asa_api_boot_failure <- function(boot_error = NULL) {
|
| 349 |
+
text <- trimws(asa_api_scalar_chr(boot_error, default = ""))
|
| 350 |
+
if (!nzchar(text)) {
|
| 351 |
+
return(NULL)
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
if (isTRUE(asa_api_is_auth_config_error(text))) {
|
| 355 |
+
return(list(
|
| 356 |
+
status_code = 503L,
|
| 357 |
+
message = "Service unavailable: authentication is not configured.",
|
| 358 |
+
error_code = "auth_config_missing",
|
| 359 |
+
details = list(
|
| 360 |
+
missing_env_vars = asa_api_extract_missing_auth_env_vars(text)
|
| 361 |
+
)
|
| 362 |
+
))
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
list(
|
| 366 |
+
status_code = 503L,
|
| 367 |
+
message = "Service unavailable."
|
| 368 |
+
)
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
asa_api_refresh_auth_cache <- function(force = FALSE) {
|
| 372 |
cached_api_hash <- .asa_api_auth_cache$api_bearer_token_hash %||% ""
|
| 373 |
cached_gui_hash <- .asa_api_auth_cache$gui_password_hash %||% ""
|
|
|
|
| 399 |
invisible(TRUE)
|
| 400 |
}
|
| 401 |
|
| 402 |
+
asa_api_auth_check_secret <- function(candidate, cache_key, auth_target) {
|
| 403 |
supplied <- asa_api_scalar_chr(candidate, default = "")
|
| 404 |
+
refresh_error <- NULL
|
|
|
|
|
|
|
| 405 |
|
| 406 |
+
auth_result <- tryCatch(
|
| 407 |
{
|
| 408 |
asa_api_refresh_auth_cache()
|
| 409 |
stored_hash <- .asa_api_auth_cache[[asa_api_scalar_chr(cache_key, default = "")]] %||% ""
|
| 410 |
+
if (nzchar(supplied) &&
|
| 411 |
+
is.character(stored_hash) &&
|
| 412 |
+
nzchar(stored_hash) &&
|
| 413 |
+
isTRUE(sodium::password_verify(stored_hash, supplied))) {
|
| 414 |
+
return(list(ok = TRUE))
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
list(
|
| 418 |
+
ok = FALSE,
|
| 419 |
+
status_code = 401L,
|
| 420 |
+
message = "Unauthorized: provided credential did not match the configured value.",
|
| 421 |
+
error_code = "credential_mismatch",
|
| 422 |
+
details = list(
|
| 423 |
+
auth_target = asa_api_scalar_chr(auth_target, default = "")
|
| 424 |
+
)
|
| 425 |
+
)
|
| 426 |
},
|
| 427 |
+
error = function(e) {
|
| 428 |
+
refresh_error <<- conditionMessage(e)
|
| 429 |
+
NULL
|
| 430 |
+
}
|
| 431 |
+
)
|
| 432 |
+
if (!is.null(auth_result)) {
|
| 433 |
+
return(auth_result)
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
boot_failure <- asa_api_boot_failure(refresh_error)
|
| 437 |
+
if (!is.null(boot_failure)) {
|
| 438 |
+
return(c(list(ok = FALSE), boot_failure))
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
list(
|
| 442 |
+
ok = FALSE,
|
| 443 |
+
status_code = 503L,
|
| 444 |
+
message = "Service unavailable."
|
| 445 |
)
|
| 446 |
}
|
| 447 |
|
|
|
|
| 459 |
""
|
| 460 |
}
|
| 461 |
|
| 462 |
+
asa_api_check_bearer_token <- function(req) {
|
| 463 |
+
asa_api_auth_check_secret(
|
| 464 |
asa_api_extract_bearer_token(req),
|
| 465 |
+
"api_bearer_token_hash",
|
| 466 |
+
"api_bearer_token"
|
| 467 |
+
)
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
asa_api_has_required_bearer_token <- function(req) {
|
| 471 |
+
isTRUE(asa_api_check_bearer_token(req)$ok)
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
asa_api_check_gui_password <- function(password) {
|
| 475 |
+
asa_api_auth_check_secret(
|
| 476 |
+
password,
|
| 477 |
+
"gui_password_hash",
|
| 478 |
+
"gui_password"
|
| 479 |
)
|
| 480 |
}
|
| 481 |
|
| 482 |
asa_api_has_required_gui_password <- function(password) {
|
| 483 |
+
isTRUE(asa_api_check_gui_password(password)$ok)
|
| 484 |
}
|
| 485 |
|
| 486 |
asa_api_require_prompt <- function(payload) {
|
R/plumber.R
CHANGED
|
@@ -39,9 +39,23 @@ tryCatch(
|
|
| 39 |
}
|
| 40 |
)
|
| 41 |
|
| 42 |
-
asa_api_error_payload <- function(res, status, message) {
|
| 43 |
res$status <- as.integer(status)
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
asa_api_error_status <- function(message) {
|
|
@@ -76,12 +90,14 @@ function(req, res) {
|
|
| 76 |
return(plumber::forward())
|
| 77 |
}
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
plumber::forward()
|
|
@@ -108,8 +124,9 @@ function() {
|
|
| 108 |
#* @post /v1/run
|
| 109 |
#* @serializer unboxedJSON
|
| 110 |
function(req, res) {
|
| 111 |
-
|
| 112 |
-
|
|
|
|
| 113 |
}
|
| 114 |
|
| 115 |
parse_error <- NULL
|
|
@@ -137,8 +154,9 @@ function(req, res) {
|
|
| 137 |
#* @post /v1/batch
|
| 138 |
#* @serializer unboxedJSON
|
| 139 |
function(req, res) {
|
| 140 |
-
|
| 141 |
-
|
|
|
|
| 142 |
}
|
| 143 |
|
| 144 |
parse_error <- NULL
|
|
@@ -166,8 +184,9 @@ function(req, res) {
|
|
| 166 |
#* @post /gui/query
|
| 167 |
#* @serializer unboxedJSON
|
| 168 |
function(req, res) {
|
| 169 |
-
|
| 170 |
-
|
|
|
|
| 171 |
}
|
| 172 |
|
| 173 |
parse_error <- NULL
|
|
@@ -182,8 +201,9 @@ function(req, res) {
|
|
| 182 |
return(asa_api_error_payload(res, 400L, parse_error))
|
| 183 |
}
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
|
|
|
| 187 |
}
|
| 188 |
|
| 189 |
payload$include_raw_output <- FALSE
|
|
|
|
| 39 |
}
|
| 40 |
)
|
| 41 |
|
| 42 |
+
asa_api_error_payload <- function(res, status, message, error_code = NULL, details = NULL) {
|
| 43 |
res$status <- as.integer(status)
|
| 44 |
+
asa_api_error_fields(
|
| 45 |
+
message,
|
| 46 |
+
error_code = error_code,
|
| 47 |
+
details = details
|
| 48 |
+
)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
asa_api_failure_payload <- function(res, failure) {
|
| 52 |
+
asa_api_error_payload(
|
| 53 |
+
res,
|
| 54 |
+
failure$status_code %||% 500L,
|
| 55 |
+
failure$message %||% "Request failed.",
|
| 56 |
+
error_code = failure$error_code %||% NULL,
|
| 57 |
+
details = failure$details %||% NULL
|
| 58 |
+
)
|
| 59 |
}
|
| 60 |
|
| 61 |
asa_api_error_status <- function(message) {
|
|
|
|
| 90 |
return(plumber::forward())
|
| 91 |
}
|
| 92 |
|
| 93 |
+
boot_failure <- asa_api_boot_failure(.asa_api_boot_error)
|
| 94 |
+
if (!is.null(boot_failure)) {
|
| 95 |
+
return(asa_api_failure_payload(res, boot_failure))
|
| 96 |
}
|
| 97 |
|
| 98 |
+
auth_check <- asa_api_check_bearer_token(req)
|
| 99 |
+
if (!isTRUE(auth_check$ok)) {
|
| 100 |
+
return(asa_api_failure_payload(res, auth_check))
|
| 101 |
}
|
| 102 |
|
| 103 |
plumber::forward()
|
|
|
|
| 124 |
#* @post /v1/run
|
| 125 |
#* @serializer unboxedJSON
|
| 126 |
function(req, res) {
|
| 127 |
+
boot_failure <- asa_api_boot_failure(.asa_api_boot_error)
|
| 128 |
+
if (!is.null(boot_failure)) {
|
| 129 |
+
return(asa_api_failure_payload(res, boot_failure))
|
| 130 |
}
|
| 131 |
|
| 132 |
parse_error <- NULL
|
|
|
|
| 154 |
#* @post /v1/batch
|
| 155 |
#* @serializer unboxedJSON
|
| 156 |
function(req, res) {
|
| 157 |
+
boot_failure <- asa_api_boot_failure(.asa_api_boot_error)
|
| 158 |
+
if (!is.null(boot_failure)) {
|
| 159 |
+
return(asa_api_failure_payload(res, boot_failure))
|
| 160 |
}
|
| 161 |
|
| 162 |
parse_error <- NULL
|
|
|
|
| 184 |
#* @post /gui/query
|
| 185 |
#* @serializer unboxedJSON
|
| 186 |
function(req, res) {
|
| 187 |
+
boot_failure <- asa_api_boot_failure(.asa_api_boot_error)
|
| 188 |
+
if (!is.null(boot_failure)) {
|
| 189 |
+
return(asa_api_failure_payload(res, boot_failure))
|
| 190 |
}
|
| 191 |
|
| 192 |
parse_error <- NULL
|
|
|
|
| 201 |
return(asa_api_error_payload(res, 400L, parse_error))
|
| 202 |
}
|
| 203 |
|
| 204 |
+
auth_check <- asa_api_check_gui_password(payload$password)
|
| 205 |
+
if (!isTRUE(auth_check$ok)) {
|
| 206 |
+
return(asa_api_failure_payload(res, auth_check))
|
| 207 |
}
|
| 208 |
|
| 209 |
payload$include_raw_output <- FALSE
|
README.md
CHANGED
|
@@ -33,6 +33,9 @@ It uses:
|
|
| 33 |
- GUI auth is password-based:
|
| 34 |
- `/gui/query` verifies the submitted password against an in-memory hash derived from `GUI_PASSWORD`
|
| 35 |
- The service fails closed if either auth secret env var is missing or blank.
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
## Required Environment Variables
|
| 38 |
|
|
|
|
| 33 |
- GUI auth is password-based:
|
| 34 |
- `/gui/query` verifies the submitted password against an in-memory hash derived from `GUI_PASSWORD`
|
| 35 |
- The service fails closed if either auth secret env var is missing or blank.
|
| 36 |
+
- Auth errors are diagnostic but safe:
|
| 37 |
+
- missing auth env vars return `503` with `error_code: "auth_config_missing"` and `details.missing_env_vars`
|
| 38 |
+
- wrong GUI/API credentials return `401` with `error_code: "credential_mismatch"` and `details.auth_target`
|
| 39 |
|
| 40 |
## Required Environment Variables
|
| 41 |
|
Tests/api_contract_smoke.R
CHANGED
|
@@ -170,6 +170,38 @@ assert_true(
|
|
| 170 |
identical(asa_api_missing_auth_env_vars(), character(0)),
|
| 171 |
"Auth bootstrap should require explicit bearer-token and GUI-password env vars."
|
| 172 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
assert_true(
|
| 174 |
is.character(.asa_api_auth_cache$api_bearer_token_hash) &&
|
| 175 |
nzchar(.asa_api_auth_cache$api_bearer_token_hash) &&
|
|
@@ -218,9 +250,35 @@ assert_true(
|
|
| 218 |
isTRUE(asa_api_has_required_gui_password(auth_fixture[["GUI_PASSWORD"]])),
|
| 219 |
"GUI auth should accept the configured password."
|
| 220 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
assert_true(
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
| 224 |
)
|
| 225 |
|
| 226 |
asa_api_clear_auth_cache()
|
|
@@ -229,6 +287,14 @@ expect_error_contains(
|
|
| 229 |
asa_api_refresh_auth_cache(force = TRUE),
|
| 230 |
"ASA_API_BEARER_TOKEN"
|
| 231 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
do.call(Sys.setenv, stats::setNames(list(auth_fixture[["ASA_API_BEARER_TOKEN"]]), "ASA_API_BEARER_TOKEN"))
|
| 233 |
|
| 234 |
asa_api_clear_auth_cache()
|
|
@@ -240,6 +306,22 @@ expect_error_contains(
|
|
| 240 |
do.call(Sys.setenv, stats::setNames(list(auth_fixture[["GUI_PASSWORD"]]), "GUI_PASSWORD"))
|
| 241 |
asa_api_refresh_auth_cache(force = TRUE)
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
health_payload <- asa_api_health_payload()
|
| 244 |
assert_true(
|
| 245 |
identical(isTRUE(health_payload$direct_provider_available), isTRUE(asa_api_has_run_direct_task())),
|
|
|
|
| 170 |
identical(asa_api_missing_auth_env_vars(), character(0)),
|
| 171 |
"Auth bootstrap should require explicit bearer-token and GUI-password env vars."
|
| 172 |
)
|
| 173 |
+
auth_config_boot_failure <- asa_api_boot_failure(
|
| 174 |
+
"Missing required authentication environment variable(s): `GUI_PASSWORD`, `ASA_API_BEARER_TOKEN`."
|
| 175 |
+
)
|
| 176 |
+
assert_true(
|
| 177 |
+
identical(auth_config_boot_failure$status_code, 503L) &&
|
| 178 |
+
identical(auth_config_boot_failure$error_code, "auth_config_missing") &&
|
| 179 |
+
identical(
|
| 180 |
+
sort(auth_config_boot_failure$details$missing_env_vars),
|
| 181 |
+
sort(c("GUI_PASSWORD", "ASA_API_BEARER_TOKEN"))
|
| 182 |
+
),
|
| 183 |
+
"Boot failures caused by missing auth env vars should expose a structured safe error."
|
| 184 |
+
)
|
| 185 |
+
generic_boot_failure <- asa_api_boot_failure("Package `asa` is not installed in this environment.")
|
| 186 |
+
assert_true(
|
| 187 |
+
identical(generic_boot_failure$status_code, 503L) &&
|
| 188 |
+
identical(generic_boot_failure$message, "Service unavailable.") &&
|
| 189 |
+
is.null(generic_boot_failure$error_code),
|
| 190 |
+
"Non-auth boot failures should remain generic in request responses."
|
| 191 |
+
)
|
| 192 |
+
auth_config_payload <- asa_api_error_fields(
|
| 193 |
+
auth_config_boot_failure$message,
|
| 194 |
+
auth_config_boot_failure$error_code,
|
| 195 |
+
auth_config_boot_failure$details
|
| 196 |
+
)
|
| 197 |
+
assert_true(
|
| 198 |
+
identical(auth_config_payload$error_code, "auth_config_missing") &&
|
| 199 |
+
identical(
|
| 200 |
+
sort(auth_config_payload$details$missing_env_vars),
|
| 201 |
+
sort(c("GUI_PASSWORD", "ASA_API_BEARER_TOKEN"))
|
| 202 |
+
),
|
| 203 |
+
"Structured auth-config failures should render with error_code and missing_env_vars details."
|
| 204 |
+
)
|
| 205 |
assert_true(
|
| 206 |
is.character(.asa_api_auth_cache$api_bearer_token_hash) &&
|
| 207 |
nzchar(.asa_api_auth_cache$api_bearer_token_hash) &&
|
|
|
|
| 250 |
isTRUE(asa_api_has_required_gui_password(auth_fixture[["GUI_PASSWORD"]])),
|
| 251 |
"GUI auth should accept the configured password."
|
| 252 |
)
|
| 253 |
+
gui_mismatch <- asa_api_check_gui_password("wrong-password")
|
| 254 |
+
assert_true(
|
| 255 |
+
identical(gui_mismatch$ok, FALSE) &&
|
| 256 |
+
identical(gui_mismatch$status_code, 401L) &&
|
| 257 |
+
identical(gui_mismatch$error_code, "credential_mismatch") &&
|
| 258 |
+
identical(gui_mismatch$details$auth_target, "gui_password"),
|
| 259 |
+
"GUI auth should report credential mismatches with a structured safe error."
|
| 260 |
+
)
|
| 261 |
+
gui_mismatch_payload <- asa_api_error_fields(
|
| 262 |
+
gui_mismatch$message,
|
| 263 |
+
gui_mismatch$error_code,
|
| 264 |
+
gui_mismatch$details
|
| 265 |
+
)
|
| 266 |
+
assert_true(
|
| 267 |
+
identical(
|
| 268 |
+
gui_mismatch_payload$error,
|
| 269 |
+
"Unauthorized: provided credential did not match the configured value."
|
| 270 |
+
) &&
|
| 271 |
+
identical(gui_mismatch_payload$error_code, "credential_mismatch") &&
|
| 272 |
+
identical(gui_mismatch_payload$details$auth_target, "gui_password"),
|
| 273 |
+
"GUI credential mismatches should render the expected response fields."
|
| 274 |
+
)
|
| 275 |
+
api_mismatch <- asa_api_check_bearer_token(mock_request(authorization = "Bearer wrong"))
|
| 276 |
assert_true(
|
| 277 |
+
identical(api_mismatch$ok, FALSE) &&
|
| 278 |
+
identical(api_mismatch$status_code, 401L) &&
|
| 279 |
+
identical(api_mismatch$error_code, "credential_mismatch") &&
|
| 280 |
+
identical(api_mismatch$details$auth_target, "api_bearer_token"),
|
| 281 |
+
"API auth should report bearer-token mismatches with a structured safe error."
|
| 282 |
)
|
| 283 |
|
| 284 |
asa_api_clear_auth_cache()
|
|
|
|
| 287 |
asa_api_refresh_auth_cache(force = TRUE),
|
| 288 |
"ASA_API_BEARER_TOKEN"
|
| 289 |
)
|
| 290 |
+
gui_missing_auth_config <- asa_api_check_gui_password("anything")
|
| 291 |
+
assert_true(
|
| 292 |
+
identical(gui_missing_auth_config$ok, FALSE) &&
|
| 293 |
+
identical(gui_missing_auth_config$status_code, 503L) &&
|
| 294 |
+
identical(gui_missing_auth_config$error_code, "auth_config_missing") &&
|
| 295 |
+
identical(gui_missing_auth_config$details$missing_env_vars, "ASA_API_BEARER_TOKEN"),
|
| 296 |
+
"Missing auth env vars should surface as structured startup misconfiguration errors."
|
| 297 |
+
)
|
| 298 |
do.call(Sys.setenv, stats::setNames(list(auth_fixture[["ASA_API_BEARER_TOKEN"]]), "ASA_API_BEARER_TOKEN"))
|
| 299 |
|
| 300 |
asa_api_clear_auth_cache()
|
|
|
|
| 306 |
do.call(Sys.setenv, stats::setNames(list(auth_fixture[["GUI_PASSWORD"]]), "GUI_PASSWORD"))
|
| 307 |
asa_api_refresh_auth_cache(force = TRUE)
|
| 308 |
|
| 309 |
+
source(file.path(repo_root, "R", "plumber.R"))
|
| 310 |
+
res_env <- new.env(parent = emptyenv())
|
| 311 |
+
rendered_payload <- asa_api_error_payload(
|
| 312 |
+
res_env,
|
| 313 |
+
gui_mismatch$status_code,
|
| 314 |
+
gui_mismatch$message,
|
| 315 |
+
gui_mismatch$error_code,
|
| 316 |
+
gui_mismatch$details
|
| 317 |
+
)
|
| 318 |
+
assert_true(
|
| 319 |
+
identical(res_env$status, 401L) &&
|
| 320 |
+
identical(rendered_payload$error_code, "credential_mismatch") &&
|
| 321 |
+
identical(rendered_payload$details$auth_target, "gui_password"),
|
| 322 |
+
"Route error rendering should preserve structured auth diagnostics."
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
health_payload <- asa_api_health_payload()
|
| 326 |
assert_true(
|
| 327 |
identical(isTRUE(health_payload$direct_provider_available), isTRUE(asa_api_has_run_direct_task())),
|