Expected `values$error` to be identical to "token_exchange_error".
Differences:
`actual`:   "invalid_callback_query"
`expected`: "token_exchange_error"

testthat::test_that("manual login flow yields authenticated TRUE on success", {
# Ensure module skips browser cookie dependency in tests
cli <- make_test_client(use_pkce = TRUE, use_nonce = FALSE)
auto_redirect = FALSE, # we'll drive the flow manually
indefinite_session = TRUE # avoid expiry-related flakes
# We should have a synthetic browser token available in tests
# Mock token exchange to avoid real HTTP
swap_code_for_token_set = function(client, code, code_verifier) {
list(access_token = "t", expires_in = 3600)
testthat::test_that("login fails when introspection validation fails", {
headers = list("content-type" = "application/json"),
grepl("not active", values$error_description, ignore.case = TRUE)
testthat::test_that("auto_redirect triggers when unauthenticated and cookie present", {
# Simulate the initial empty query string load
testthat::test_that("authenticated becomes FALSE for expired token (default mode)", {
expires_at = as.numeric(Sys.time()) - 10,
# Observer recalculates authenticated when token changes
testthat::test_that("reauth_after_seconds makes authenticated FALSE when max age exceeded", {
# Valid token not expired, but make session "old"
expires_at = as.numeric(Sys.time()) + 3600,
values$auth_started_at <- as.numeric(Sys.time()) - 5
# Trigger recompute: poke token (no-op) to invalidate observer deps
testthat::test_that("authenticated flips FALSE after expiry without poking reactive values", {
# This test verifies that the authenticated observer includes time-based
# re-evaluation logic. Since invalidateLater() timers don't fire reliably in
# testServer, we verify the underlying behavior: after time passes, any
# reactive flush should pick up the expired state.
# Seed a token that expires very soon (200ms from now)
expires_at = as.numeric(Sys.time()) + expire_in_secs,
# Immediately after setting, authenticated should be TRUE
# After time passes, reassigning the token (even to same value) should
# trigger the authenticated observer to re-run. This verifies that
# .compute_authenticated() correctly detects expiry via Sys.time().
# In a real app, invalidateLater() schedules this automatically.
# Authenticated should now be FALSE because .compute_authenticated()
testthat::test_that("authenticated flips FALSE after reauth_after_seconds without poking", {
# Same approach as expiry test - verify the logic works when time passes.
# Token with long expiry, but short reauth window
# Trigger reactive re-evaluation by toggling token
testthat::test_that("authenticated TRUE for token with NA expires_at (no expiry)", {
# Token with NA expires_at should be treated as non-expiring
testthat::test_that("authenticated TRUE for token with Inf expires_at", {
# Token with Inf expires_at should be treated as non-expiring
testthat::test_that("authenticated FALSE when error is set (default mode)", {
# Setting error should flip authenticated to FALSE
# Clearing error should restore authenticated
testthat::test_that("authenticated FALSE when token is cleared", {
# Clearing token should flip authenticated to FALSE
testthat::test_that("authenticated correct at exact expiry boundary", {
# Token expiring exactly now (edge case: now >= exp is FALSE)
# At exact boundary, now >= exp should be TRUE, so authenticated = FALSE
testthat::test_that("indefinite_session keeps authenticated TRUE even when expired or error", {
# Even with an error, authenticated should remain TRUE in indefinite mode
# Poke token to trigger compute observer
testthat::test_that("provider error in query sets error and authenticated FALSE", {
sess$sendCustomMessage <- function(type, message) {
info = "Expected clearQueryAndFixTitle on provider error response"
testthat::test_that("oversized callback query params are rejected", {
# Oversized code should short-circuit before token exchange
# Oversized error_description should also be rejected
testthat::test_that("oversized raw callback query string is rejected", {
# Construct a very large query string that would otherwise proceed to
# token exchange (code+state are valid), but should be rejected before
testthat::test_that("callback_max_query_bytes option is enforced", {
query_bytes <- nchar(query, type = "bytes")
# Too-small cap rejects the query before parsing continues
shinyOAuth.callback_max_query_bytes = query_bytes - 1
testthat::expect_match(values$error_description %||% "", "query string")
# Large enough cap allows the normal flow to proceed
shinyOAuth.callback_max_query_bytes = query_bytes + 1
testthat::test_that("callback code/state clears query even when token exchange fails", {
info = "Expected clearQueryAndFixTitle on token-exchange error"
testthat::test_that("strip_oauth_query removes only OAuth params", {
testthat::test_that("oauth_module_server clears token and sets error when proactive refresh fails", {
# Integration test: trigger the module's proactive refresh observer and
# assert that the module (not the test) clears token and sets error.
# Make the lead window larger than remaining so refresh is attempted
# immediately, but keep expiry far enough in the future to avoid the
# separate expiry watcher racing this test.
# Seed a token that is "valid" but will be proactively refreshed
# Ensure we start from a clean error state
# Force refresh_token() to error as if ID token validation failed.
# Pump the event loop until the observer runs or timeout
while (is.null(values$error) && Sys.time() < deadline) {