Refresh tests pin the production edges: key-fetch typing, cache wiring, algorithm gate, cadence
VerificationUnavailable now carries an error class and never the fetch error, whose text named the key-set URL. fetch_jwks returns the reqwest error so the class is read from it, not from a string. run_refresh_loop loses its period parameter: SESSION_REFRESH_PERIOD is decided in one place and a paused-clock test observes it exactly and over twelve ticks. Tests drive HeimdallClient::verify_access against a loopback issuer that counts fetches (cache stored, read, ttl 299 s fresh and 301 s stale), a closed port and a 503 (kept session and receipt, no URL in the error or the log line), and nine wrong-algorithm tokens. The moved-session test also pins that the newer claim is the one spent next. Builds and passes under ygg-verify (28 of 28 in heimdall:: and refresh_loop_tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
57b16df286
commit
26f533e8f4
3 changed files with 325 additions and 48 deletions
|
|
@ -43,4 +43,5 @@ zeroize.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
|
|
|||
|
|
@ -45,10 +45,35 @@ pub(crate) struct HeimdallDenied {
|
|||
/// Heimdall's signing keys could not be fetched, so a receipt could not be
|
||||
/// judged either way. Distinct from a receipt that positively fails
|
||||
/// verification: the refresh loop keeps the receipt for this and revokes for
|
||||
/// that.
|
||||
/// that. It carries an error class and never the fetch error itself, whose
|
||||
/// text names the key-set URL: an error never echoes an input value.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("Heimdall signing keys unavailable: {0}")]
|
||||
pub(crate) struct VerificationUnavailable(pub(crate) String);
|
||||
#[error("Heimdall signing keys unavailable: the key-set fetch {class}")]
|
||||
pub(crate) struct VerificationUnavailable {
|
||||
class: &'static str,
|
||||
}
|
||||
|
||||
impl VerificationUnavailable {
|
||||
fn of_fetch(error: &reqwest::Error) -> Self {
|
||||
let class = if error.is_timeout() {
|
||||
"timed out"
|
||||
} else if error.is_connect() {
|
||||
"could not connect"
|
||||
} else if error.is_status() {
|
||||
"returned an error status"
|
||||
} else if error.is_decode() {
|
||||
"returned an unreadable key set"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
Self { class }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(class: &'static str) -> Self {
|
||||
Self { class }
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a fetched signing-key set is trusted before it is fetched again.
|
||||
const JWKS_TTL: Duration = Duration::from_secs(300);
|
||||
|
|
@ -491,6 +516,13 @@ impl HeimdallClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// The same client against another issuer, for tests that stand up their own.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
|
||||
self.issuer = issuer.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn begin(&self, idempotency_key: &str) -> anyhow::Result<AuthBeginReceipt> {
|
||||
let payload = BeginCommand {
|
||||
provider: "discord",
|
||||
|
|
@ -606,15 +638,14 @@ impl HeimdallClient {
|
|||
)?))
|
||||
}
|
||||
|
||||
async fn fetch_jwks(&self) -> anyhow::Result<JwkSet> {
|
||||
Ok(self
|
||||
.http
|
||||
async fn fetch_jwks(&self) -> Result<JwkSet, reqwest::Error> {
|
||||
self.http
|
||||
.get(format!("{}/.well-known/jwks.json", self.issuer))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn verify_access(&self, token: &str) -> anyhow::Result<AccessClaims> {
|
||||
|
|
@ -630,7 +661,7 @@ impl HeimdallClient {
|
|||
let set = self
|
||||
.fetch_jwks()
|
||||
.await
|
||||
.map_err(|error| VerificationUnavailable(format!("{error:#}")))?;
|
||||
.map_err(|error| VerificationUnavailable::of_fetch(&error))?;
|
||||
let jwk = set.find(&kid).cloned();
|
||||
self.jwks
|
||||
.lock()
|
||||
|
|
@ -1143,10 +1174,190 @@ fn required_env(name: &str) -> anyhow::Result<String> {
|
|||
Ok(value.to_owned())
|
||||
}
|
||||
|
||||
/// A local stand-in for Heimdall's issuer: it serves the key set (or a 5xx)
|
||||
/// on a loopback port and counts the requests it receives. The key is the
|
||||
/// RFC 8037 appendix A.1 Ed25519 test key.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_issuer {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
|
||||
pub(crate) const KID: &str = "k1";
|
||||
const PRIVATE_D: &str = "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A";
|
||||
const PUBLIC_X: &str = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum Answer {
|
||||
KeySet,
|
||||
ServerError,
|
||||
}
|
||||
|
||||
pub(crate) struct Issuer {
|
||||
pub(crate) url: String,
|
||||
fetches: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Issuer {
|
||||
pub(crate) async fn start(answer: Answer) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let fetches = Arc::new(AtomicUsize::new(0));
|
||||
let counted = fetches.clone();
|
||||
let key_set = serde_json::json!({
|
||||
"keys": [{"kty": "OKP", "crv": "Ed25519", "kid": KID, "x": PUBLIC_X}]
|
||||
})
|
||||
.to_string();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut stream, _)) = listener.accept().await {
|
||||
let mut request = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
match stream.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(read) => request.extend_from_slice(&chunk[..read]),
|
||||
}
|
||||
}
|
||||
counted.fetch_add(1, Ordering::SeqCst);
|
||||
let (status, body) = match answer {
|
||||
Answer::KeySet => ("200 OK", key_set.as_str()),
|
||||
Answer::ServerError => ("503 Service Unavailable", ""),
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\n\
|
||||
content-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.shutdown().await;
|
||||
}
|
||||
});
|
||||
Self { url, fetches }
|
||||
}
|
||||
|
||||
pub(crate) fn fetches(&self) -> usize {
|
||||
self.fetches.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// The URL of a loopback port nothing listens on.
|
||||
pub(crate) fn closed_url() -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
format!("http://{}", listener.local_addr().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
/// An access token Heimdall's key signs for `issuer`, with the claims
|
||||
/// `verify_access` demands.
|
||||
pub(crate) fn signed_token(issuer: &str) -> String {
|
||||
let mut der = vec![
|
||||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
|
||||
0x04, 0x20,
|
||||
];
|
||||
der.extend(URL_SAFE_NO_PAD.decode(PRIVATE_D).unwrap());
|
||||
let pem = format!(
|
||||
"-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n",
|
||||
STANDARD.encode(der)
|
||||
);
|
||||
let mut header = jsonwebtoken::Header::new(Algorithm::EdDSA);
|
||||
header.kid = Some(KID.into());
|
||||
let claims = serde_json::json!({
|
||||
"iss": issuer, "aud": APP_SLUG, "sub": "account-1", "sid": "session-1",
|
||||
"exp": Utc::now().timestamp() + 300, "typ": "heimdall_access",
|
||||
"account_id": "account-1", "access_revision": 1,
|
||||
"capabilities": ["app_access"], "app": {"slug": APP_SLUG},
|
||||
});
|
||||
jsonwebtoken::encode(
|
||||
&header,
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_ed_pem(pem.as_bytes()).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use super::test_issuer::{Answer, Issuer, signed_token};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_key_fetch_failure_is_typed_unavailable_and_names_no_url() {
|
||||
let closed = Issuer::closed_url();
|
||||
let failing = Issuer::start(Answer::ServerError).await;
|
||||
for (base, class) in [
|
||||
(closed, "could not connect"),
|
||||
(failing.url.clone(), "returned an error status"),
|
||||
] {
|
||||
let canary = format!("{base}/canary-q7x1");
|
||||
let client = HeimdallClient::fixture().with_issuer(canary.clone());
|
||||
let error = client
|
||||
.verify_access(&signed_token(&canary))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.downcast_ref::<VerificationUnavailable>().is_some(), "{error:#}");
|
||||
for text in [format!("{error}"), format!("{error:#}"), format!("{error:?}")] {
|
||||
assert!(text.contains("signing keys unavailable"), "{text}");
|
||||
assert!(text.contains(class), "{text}");
|
||||
for leaked in [canary.as_str(), base.as_str(), "canary-q7x1", "127.0.0.1"] {
|
||||
assert!(!text.contains(leaked), "{leaked} leaked into {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_key_set_is_fetched_once_within_its_ttl_and_again_after_it() {
|
||||
let issuer = Issuer::start(Answer::KeySet).await;
|
||||
let client = HeimdallClient::fixture().with_issuer(issuer.url.clone());
|
||||
let token = signed_token(&issuer.url);
|
||||
client.verify_access(&token).await.unwrap();
|
||||
assert_eq!(issuer.fetches(), 1);
|
||||
client.verify_access(&token).await.unwrap();
|
||||
assert_eq!(issuer.fetches(), 1, "a second verify within the ttl reads the cache");
|
||||
let age = |seconds: u64| {
|
||||
client.jwks.lock().unwrap().fetched.as_mut().unwrap().0 =
|
||||
Instant::now().checked_sub(Duration::from_secs(seconds)).unwrap();
|
||||
};
|
||||
age(299);
|
||||
client.verify_access(&token).await.unwrap();
|
||||
assert_eq!(issuer.fetches(), 1, "299 s old is still fresh");
|
||||
age(301);
|
||||
client.verify_access(&token).await.unwrap();
|
||||
assert_eq!(issuer.fetches(), 2, "301 s old is fetched again");
|
||||
client.verify_access(&token).await.unwrap();
|
||||
assert_eq!(issuer.fetches(), 2, "and the new fetch is cached");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_token_signed_under_any_other_algorithm_is_refused_before_any_key_fetch() {
|
||||
let issuer = Issuer::start(Answer::KeySet).await;
|
||||
let client = HeimdallClient::fixture().with_issuer(issuer.url.clone());
|
||||
let part = |text: &str| URL_SAFE_NO_PAD.encode(text);
|
||||
for alg in [
|
||||
"none", "HS256", "HS384", "HS512", "RS256", "RS384", "PS256", "ES256", "ES384",
|
||||
] {
|
||||
let token = format!(
|
||||
"{}.{}.{}",
|
||||
part(&format!(r#"{{"alg":"{alg}","typ":"JWT","kid":"k1"}}"#)),
|
||||
part(r#"{"sub":"account-1"}"#),
|
||||
URL_SAFE_NO_PAD.encode([0xAB_u8; 32]),
|
||||
);
|
||||
let error = client.verify_access(&token).await.unwrap_err();
|
||||
assert!(error.downcast_ref::<VerificationUnavailable>().is_none(), "{alg}");
|
||||
if alg != "none" {
|
||||
assert!(
|
||||
error.to_string().contains("unsupported signing algorithm"),
|
||||
"{alg}: {error:#}"
|
||||
);
|
||||
}
|
||||
assert_eq!(issuer.fetches(), 0, "{alg} reached the key fetch");
|
||||
}
|
||||
}
|
||||
|
||||
fn boundary(endpoint: &str) -> HeimdallCommandBoundaryRecord {
|
||||
HeimdallCommandBoundaryRecord {
|
||||
schema: "heimdall.command_boundary.v1".into(),
|
||||
|
|
@ -1187,22 +1398,6 @@ mod tests {
|
|||
assert!(cache.key("k1", start + JWKS_TTL).is_none(), "stale at the ttl");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_token_signed_with_another_algorithm_is_refused_before_any_key_fetch() {
|
||||
let token = jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::new(Algorithm::HS256),
|
||||
&serde_json::json!({"sub": "x"}),
|
||||
&jsonwebtoken::EncodingKey::from_secret(b"secret"),
|
||||
)
|
||||
.unwrap();
|
||||
let error = HeimdallClient::fixture()
|
||||
.verify_access(&token)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("unsupported signing algorithm"), "{error:#}");
|
||||
assert!(error.downcast_ref::<VerificationUnavailable>().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_denied_status_is_the_typed_denial_and_any_other_status_passes() {
|
||||
let diagnostics = vec!["Idempotency key was reused".to_owned()];
|
||||
|
|
|
|||
|
|
@ -1863,23 +1863,21 @@ fn session_cookie(raw: &str, expires_at: DateTime<Utc>, now: DateTime<Utc>) -> H
|
|||
}
|
||||
|
||||
async fn maintain_session_refresh(state: AppState) -> anyhow::Result<()> {
|
||||
run_refresh_loop(
|
||||
&state.sessions,
|
||||
state.heimdall.as_ref(),
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
.await
|
||||
run_refresh_loop(&state.sessions, state.heimdall.as_ref()).await
|
||||
}
|
||||
|
||||
/// Runs `refresh_due_sessions` once per `period` for as long as the daemon
|
||||
/// serves. The receipts it holds are Heimdall refreshes already spent
|
||||
/// upstream whose verification could not yet be judged.
|
||||
/// How often the refresh loop scans for sessions due for refresh. Decided
|
||||
/// here and nowhere else.
|
||||
const SESSION_REFRESH_PERIOD: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Runs `refresh_due_sessions` once per `SESSION_REFRESH_PERIOD` for as long
|
||||
/// as the daemon serves. The receipts it holds are Heimdall refreshes already
|
||||
/// spent upstream whose verification could not yet be judged.
|
||||
async fn run_refresh_loop(
|
||||
sessions: &Mutex<AppSessionOwner>,
|
||||
heimdall: &impl SessionRefresher,
|
||||
period: Duration,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut interval = tokio::time::interval(period);
|
||||
let mut interval = tokio::time::interval(SESSION_REFRESH_PERIOD);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut unverified = HashMap::new();
|
||||
loop {
|
||||
|
|
@ -6481,6 +6479,9 @@ mod refresh_loop_tests {
|
|||
moving: Option<std::sync::Arc<Mutex<AppSessionOwner>>>,
|
||||
pending: StdMutex<Option<VerifiedSessionRefresh>>,
|
||||
calls: StdMutex<Vec<(String, String)>>,
|
||||
/// Verify receipts with this real client, against the access token
|
||||
/// each receipt carries, instead of from the verdict queue.
|
||||
real: Option<(HeimdallClient, String)>,
|
||||
}
|
||||
|
||||
impl ScriptedHeimdall {
|
||||
|
|
@ -6496,6 +6497,11 @@ mod refresh_loop_tests {
|
|||
self
|
||||
}
|
||||
|
||||
fn verifying_for_real(mut self, client: HeimdallClient, token: String) -> Self {
|
||||
self.real = Some((client, token));
|
||||
self
|
||||
}
|
||||
|
||||
fn verifications(&self) -> usize {
|
||||
*self.verifications.lock().unwrap()
|
||||
}
|
||||
|
|
@ -6566,7 +6572,7 @@ mod refresh_loop_tests {
|
|||
error: None,
|
||||
account: None,
|
||||
session: None,
|
||||
access_token: None,
|
||||
access_token: self.real.as_ref().map(|(_, token)| token.clone()),
|
||||
refresh_token: None,
|
||||
refresh: None,
|
||||
shared_capabilities: Vec::new(),
|
||||
|
|
@ -6577,12 +6583,15 @@ mod refresh_loop_tests {
|
|||
|
||||
async fn verify_refresh(
|
||||
&self,
|
||||
_completion: AuthCompletionReceipt,
|
||||
completion: AuthCompletionReceipt,
|
||||
) -> anyhow::Result<VerifiedSessionRefresh> {
|
||||
*self.verifications.lock().unwrap() += 1;
|
||||
if let Some((client, _)) = &self.real {
|
||||
return client.verify_refresh(completion).await;
|
||||
}
|
||||
match self.verdicts.lock().unwrap().pop_front() {
|
||||
Some(Verdict::Unavailable) => {
|
||||
Err(VerificationUnavailable("jwks fetch timed out".into()).into())
|
||||
Err(VerificationUnavailable::new("timed out").into())
|
||||
}
|
||||
Some(Verdict::Invalid) => bail!("Heimdall access token signature is invalid"),
|
||||
None => Ok(self.pending.lock().unwrap().take().expect("scripted receipt")),
|
||||
|
|
@ -6814,6 +6823,12 @@ mod refresh_loop_tests {
|
|||
heimdall.moving = Some(sessions.clone());
|
||||
pass(&sessions, &heimdall).await;
|
||||
assert!(is_live(&sessions, &cookie).await);
|
||||
// The newer state wins: the next refresh spends its claim, not the
|
||||
// stale receipt's.
|
||||
heimdall.moving = None;
|
||||
heimdall.replies.lock().unwrap().push_back(Reply::Timeout);
|
||||
pass(&sessions, &heimdall).await;
|
||||
assert_eq!(heimdall.calls()[1].0, "claim-moved");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -6832,16 +6847,82 @@ mod refresh_loop_tests {
|
|||
assert!(!is_live(&sessions, &cookie).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_refresh_loop_runs_a_pass_on_every_tick() {
|
||||
/// Lets the loop under test run everything that is ready, without moving
|
||||
/// the paused clock.
|
||||
async fn settle() {
|
||||
for _ in 0..16 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn the_refresh_loop_runs_a_pass_every_sixty_seconds_and_keeps_going() {
|
||||
let (_directory, sessions, _cookie) = due_session(1);
|
||||
let heimdall = ScriptedHeimdall::answering((0..200).map(|_| Reply::Timeout).collect());
|
||||
let ran = tokio::time::timeout(
|
||||
Duration::from_millis(400),
|
||||
run_refresh_loop(&sessions, &heimdall, Duration::from_millis(10)),
|
||||
)
|
||||
.await;
|
||||
assert!(ran.is_err(), "the loop never returns");
|
||||
assert!(heimdall.calls().len() >= 2, "{:?}", heimdall.calls());
|
||||
let heimdall = ScriptedHeimdall::answering((0..64).map(|_| Reply::Timeout).collect());
|
||||
let observed = async {
|
||||
settle().await;
|
||||
assert_eq!(heimdall.calls().len(), 1, "the first pass runs at once");
|
||||
tokio::time::advance(Duration::from_millis(59_999)).await;
|
||||
settle().await;
|
||||
assert_eq!(heimdall.calls().len(), 1, "no pass before sixty seconds");
|
||||
tokio::time::advance(Duration::from_millis(1)).await;
|
||||
settle().await;
|
||||
assert_eq!(heimdall.calls().len(), 2, "the second pass at sixty seconds");
|
||||
for passes in 3..=12 {
|
||||
tokio::time::advance(Duration::from_secs(60)).await;
|
||||
settle().await;
|
||||
assert_eq!(heimdall.calls().len(), passes);
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
ran = run_refresh_loop(&sessions, &heimdall) => panic!("the loop returned: {ran:?}"),
|
||||
() = observed => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A refresh receipt verified by the real client against `issuer_url`,
|
||||
/// whose key-set fetch fails. Returns whether the session stayed live,
|
||||
/// how many receipts were kept, and the log the pass wrote.
|
||||
async fn pass_through_real_verification(issuer_url: &str) -> (bool, usize, String) {
|
||||
let (_directory, sessions, cookie) = due_session(1);
|
||||
let client = HeimdallClient::fixture().with_issuer(issuer_url);
|
||||
let token = heimdall::test_issuer::signed_token(issuer_url);
|
||||
let heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt {
|
||||
revision: 1,
|
||||
claim: "claim-1",
|
||||
}])
|
||||
.verifying_for_real(client, token);
|
||||
let captured = Captured::default();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_writer(captured.clone())
|
||||
.with_ansi(false)
|
||||
.finish();
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
let mut unverified = HashMap::new();
|
||||
refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap();
|
||||
let logged = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap();
|
||||
(is_live(&sessions, &cookie).await, unverified.len(), logged)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_key_fetch_failure_in_the_real_verification_keeps_the_session_and_the_receipt() {
|
||||
let failing = heimdall::test_issuer::Issuer::start(heimdall::test_issuer::Answer::ServerError)
|
||||
.await;
|
||||
for issuer_url in [heimdall::test_issuer::Issuer::closed_url(), failing.url.clone()] {
|
||||
let (live, kept, logged) = pass_through_real_verification(&issuer_url).await;
|
||||
assert!(live, "{issuer_url}: a key-fetch failure signed the session out");
|
||||
assert_eq!(kept, 1, "{issuer_url}: the spent receipt was not kept");
|
||||
assert!(logged.contains("could not be verified yet"), "{logged}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_unverified_warning_names_the_error_class_and_no_url() {
|
||||
let canary = format!("{}/canary-q7x1", heimdall::test_issuer::Issuer::closed_url());
|
||||
let (_, _, logged) = pass_through_real_verification(&canary).await;
|
||||
assert!(logged.contains("could not connect"), "{logged}");
|
||||
for leaked in [canary.as_str(), "canary-q7x1", "127.0.0.1", "jwks.json"] {
|
||||
assert!(!logged.contains(leaked), "{leaked} leaked into the log: {logged}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue