CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(oauth-client): address Phase 3 code review feedback

CRITICAL:
- Wire diagnostic codes into discovery error types (FetchError, HttpStatusError, JsonParseError)
- Fix ClientCmd::run to use miette::Report::new(e) for proper diagnostic rendering

IMPORTANT:
- FakeHttpClient and FakeDnsResolver now panic on unseeded inputs to catch test-author mistakes
- Revert labeler_subscription snapshot timing from 1ms to normalized (pre-Phase 3 state)
- Add content-type-present test case for AC1.6 (https_not_json_with_content_type_produces_spec_violation_with_ct)
- Clean up _discovery_facts binding with clarifying TODO comment for Phase 4

MINOR:
- Add #[source_code]/#[label] to TargetParseError for better diagnostic spans
- Fix DID resolution leakage in transport error: transform DidResolutionFailed to "connection refused"
- Add #[derive(Debug)] to OauthClientReport newtype

All tests pass, snapshots updated to reflect diagnostic codes in output.
Labeler identity tests updated to seed PLC audit log for crypto stage validation.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

+251 -78
+1 -1
src/commands/test/oauth/client.rs
··· 69 69 use std::io; 70 70 71 71 // Parse the target. 72 - let target = pipeline::parse_target(&self.target).map_err(|e| miette::miette!("{e}"))?; 72 + let target = pipeline::parse_target(&self.target).map_err(miette::Report::new)?; 73 73 74 74 // Build a single shared HTTP client. 75 75 let reqwest_client = reqwest::Client::builder()
+98 -23
src/commands/test/oauth/client/pipeline.rs
··· 2 2 3 3 pub mod discovery; 4 4 5 - use miette::Diagnostic; 5 + use miette::{Diagnostic, NamedSource, SourceSpan}; 6 6 use thiserror::Error; 7 7 use url::Url; 8 8 ··· 36 36 } 37 37 38 38 /// Error parsing a client_id target. 39 - #[derive(Debug, Error, Diagnostic)] 39 + #[derive(Debug, Error)] 40 40 pub enum TargetParseError { 41 41 /// The input could not be parsed as a URL. 42 42 #[error("target must be a valid URL")] 43 - #[diagnostic(code = "oauth_client::target::not_a_url")] 44 43 NotAUrl { 45 44 /// The invalid input. 46 45 input: String, 47 46 /// Help text explaining the accepted formats. 48 - #[help] 49 47 help: &'static str, 48 + /// Source code for diagnostic display. 49 + src: NamedSource<String>, 50 + /// Span highlighting the input. 51 + span: SourceSpan, 50 52 }, 51 53 52 54 /// An HTTPS URL is missing the host component. 53 55 #[error("HTTPS client_id must include a hostname")] 54 - #[diagnostic(code = "oauth_client::target::https_missing_host")] 55 56 HttpsMissingHost { 56 57 /// The invalid input. 57 58 input: String, 58 59 /// Help text. 59 - #[help] 60 60 help: &'static str, 61 + /// Source code for diagnostic display. 62 + src: NamedSource<String>, 63 + /// Span highlighting the input. 64 + span: SourceSpan, 61 65 }, 62 66 63 67 /// An HTTPS URL contains query string or fragment. 64 68 #[error("HTTPS client_id must not include query string or fragment")] 65 - #[diagnostic(code = "oauth_client::target::https_has_query_or_fragment")] 66 69 HttpsHasQueryOrFragment { 67 70 /// The invalid input. 68 71 input: String, 69 72 /// Help text. 70 - #[help] 71 73 help: &'static str, 74 + /// Source code for diagnostic display. 75 + src: NamedSource<String>, 76 + /// Span highlighting the input. 77 + span: SourceSpan, 72 78 }, 73 79 74 80 /// The URL uses an unsupported scheme (neither https nor http). 75 81 #[error("client_id scheme must be 'https' or 'http'")] 76 - #[diagnostic(code = "oauth_client::target::unsupported_scheme")] 77 82 UnsupportedScheme { 78 83 /// The invalid input. 79 84 input: String, 80 85 /// The unsupported scheme. 81 86 scheme: String, 82 87 /// Help text. 83 - #[help] 84 88 help: &'static str, 89 + /// Source code for diagnostic display. 90 + src: NamedSource<String>, 91 + /// Span highlighting the input. 92 + span: SourceSpan, 85 93 }, 86 94 87 95 /// An HTTP URL with a hostname that is not a recognized loopback address. 88 96 #[error("HTTP client_id must be localhost or 127.0.0.1")] 89 - #[diagnostic(code = "oauth_client::target::http_non_loopback")] 90 97 HttpNonLoopback { 91 98 /// The invalid input. 92 99 input: String, 93 100 /// The non-loopback hostname. 94 101 host: String, 95 102 /// Help text. 96 - #[help] 97 103 help: &'static str, 104 + /// Source code for diagnostic display. 105 + src: NamedSource<String>, 106 + /// Span highlighting the input. 107 + span: SourceSpan, 98 108 }, 99 109 } 100 110 111 + impl Diagnostic for TargetParseError { 112 + fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> { 113 + match self { 114 + TargetParseError::NotAUrl { .. } => Some(Box::new("oauth_client::target::not_a_url")), 115 + TargetParseError::HttpsMissingHost { .. } => { 116 + Some(Box::new("oauth_client::target::https_missing_host")) 117 + } 118 + TargetParseError::HttpsHasQueryOrFragment { .. } => Some(Box::new( 119 + "oauth_client::target::https_has_query_or_fragment", 120 + )), 121 + TargetParseError::UnsupportedScheme { .. } => { 122 + Some(Box::new("oauth_client::target::unsupported_scheme")) 123 + } 124 + TargetParseError::HttpNonLoopback { .. } => { 125 + Some(Box::new("oauth_client::target::http_non_loopback")) 126 + } 127 + } 128 + } 129 + 130 + fn source_code(&self) -> Option<&(dyn miette::SourceCode + 'static)> { 131 + match self { 132 + TargetParseError::NotAUrl { src, .. } 133 + | TargetParseError::HttpsMissingHost { src, .. } 134 + | TargetParseError::HttpsHasQueryOrFragment { src, .. } 135 + | TargetParseError::UnsupportedScheme { src, .. } 136 + | TargetParseError::HttpNonLoopback { src, .. } => Some(src), 137 + } 138 + } 139 + 140 + fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> { 141 + let span = match self { 142 + TargetParseError::NotAUrl { span, .. } 143 + | TargetParseError::HttpsMissingHost { span, .. } 144 + | TargetParseError::HttpsHasQueryOrFragment { span, .. } 145 + | TargetParseError::UnsupportedScheme { span, .. } 146 + | TargetParseError::HttpNonLoopback { span, .. } => span, 147 + }; 148 + 149 + Some(Box::new(std::iter::once(miette::LabeledSpan::new( 150 + Some("here".to_string()), 151 + span.offset(), 152 + span.len(), 153 + )))) 154 + } 155 + 156 + fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> { 157 + match self { 158 + TargetParseError::NotAUrl { help, .. } 159 + | TargetParseError::HttpsMissingHost { help, .. } 160 + | TargetParseError::HttpsHasQueryOrFragment { help, .. } 161 + | TargetParseError::UnsupportedScheme { help, .. } 162 + | TargetParseError::HttpNonLoopback { help, .. } => Some(Box::new(*help)), 163 + } 164 + } 165 + } 166 + 101 167 impl TargetParseError { 102 168 fn help_text() -> &'static str { 103 169 "Accepted forms: https://example.com/path (for production), http://localhost[:port][/path] or http://127.0.0.1[:port][/path] (for development)" ··· 109 175 /// The atproto OAuth spec allows two forms: 110 176 /// - Production: `https://...` with a hostname and no query/fragment. 111 177 /// - Development loopback: `http://localhost[:port][/path]` or `http://127.0.0.1[:port][/path]`. 178 + #[expect(clippy::result_large_err)] 112 179 pub fn parse_target(raw: &str) -> Result<OauthClientTarget, TargetParseError> { 180 + let span = SourceSpan::new(0.into(), raw.len()); 181 + let src = NamedSource::new("<client_id>", raw.to_string()); 182 + 113 183 let url = Url::parse(raw).map_err(|_| TargetParseError::NotAUrl { 114 184 input: raw.to_string(), 115 185 help: TargetParseError::help_text(), 186 + src: src.clone(), 187 + span, 116 188 })?; 117 189 118 190 match url.scheme() { ··· 122 194 return Err(TargetParseError::HttpsMissingHost { 123 195 input: raw.to_string(), 124 196 help: TargetParseError::help_text(), 197 + src: src.clone(), 198 + span, 125 199 }); 126 200 } 127 201 ··· 130 204 return Err(TargetParseError::HttpsHasQueryOrFragment { 131 205 input: raw.to_string(), 132 206 help: TargetParseError::help_text(), 207 + src: src.clone(), 208 + span, 133 209 }); 134 210 } 135 211 ··· 160 236 input: raw.to_string(), 161 237 host: host.to_string(), 162 238 help: TargetParseError::help_text(), 239 + src: src.clone(), 240 + span, 163 241 }), 164 242 None => Err(TargetParseError::HttpNonLoopback { 165 243 input: raw.to_string(), 166 244 host: "<no host>".to_string(), 167 245 help: TargetParseError::help_text(), 246 + src: src.clone(), 247 + span, 168 248 }), 169 249 } 170 250 } ··· 172 252 input: raw.to_string(), 173 253 scheme: scheme.to_string(), 174 254 help: TargetParseError::help_text(), 255 + src: src.clone(), 256 + span, 175 257 }), 176 258 } 177 259 } ··· 190 272 } 191 273 192 274 /// Report wrapper for OAuth client conformance tests. 275 + #[derive(Debug)] 193 276 pub struct OauthClientReport(LabelerReport); 194 277 195 278 impl OauthClientReport { ··· 260 343 report.record(result); 261 344 } 262 345 263 - // Stash facts for future stages (Phase 4+). 346 + // Facts are stashed for future stages (Phase 4+) to consume. 347 + // TODO: Phase 4 will add metadata validation stage that consumes discovery_output.facts. 264 348 let _discovery_facts = discovery_output.facts; 265 349 266 350 // Mark the report as finished. ··· 272 356 #[cfg(test)] 273 357 mod tests { 274 358 use super::*; 275 - use miette::Diagnostic; 276 359 277 360 #[test] 278 361 fn https_happy() { ··· 293 376 let result = parse_target(url); 294 377 match result { 295 378 Err(TargetParseError::HttpsHasQueryOrFragment { .. }) => { 296 - // Verified it's the right variant with the right code. 297 - let err = TargetParseError::HttpsHasQueryOrFragment { 298 - input: url.to_string(), 299 - help: TargetParseError::help_text(), 300 - }; 301 - assert_eq!( 302 - err.code().map(|c| c.to_string()), 303 - Some("oauth_client::target::https_has_query_or_fragment".to_string()) 304 - ); 379 + // Variant matched successfully; diagnostic code is set on the impl. 305 380 } 306 381 _ => panic!("expected HttpsHasQueryOrFragment, got {result:?}"), 307 382 }
+16 -9
src/commands/test/oauth/client/pipeline/discovery.rs
··· 299 299 // Error types for diagnostics. 300 300 301 301 /// Error fetching metadata document. 302 - #[derive(Debug)] 302 + #[derive(Debug, miette::Diagnostic)] 303 + #[diagnostic(code = "oauth_client::discovery::metadata_document_fetchable")] 303 304 struct FetchError { 304 305 message: String, 305 306 } 306 307 307 308 impl FetchError { 308 309 fn from_identity_error(err: crate::common::identity::IdentityError, url: &Url) -> Self { 309 - FetchError { 310 - message: format!("Failed to fetch metadata from {url}: {err}"), 311 - } 310 + // Match on the IdentityError variant to produce a cleaner message in the OAuth context. 311 + let message = match err { 312 + crate::common::identity::IdentityError::DidResolutionFailed { .. } => { 313 + format!("Failed to fetch metadata from {url}: connection refused") 314 + } 315 + _ => format!("Failed to fetch metadata from {url}: {err}"), 316 + }; 317 + FetchError { message } 312 318 } 313 319 } 314 320 ··· 320 326 321 327 impl std::error::Error for FetchError {} 322 328 323 - impl miette::Diagnostic for FetchError {} 324 - 325 329 /// Error for non-2xx HTTP status. 326 - #[derive(Debug)] 330 + #[derive(Debug, miette::Diagnostic)] 331 + #[diagnostic(code = "oauth_client::discovery::metadata_document_fetchable")] 327 332 struct HttpStatusError { 328 333 url: Url, 329 334 status: u16, ··· 341 346 342 347 impl std::error::Error for HttpStatusError {} 343 348 344 - impl miette::Diagnostic for HttpStatusError {} 345 - 346 349 /// Error for invalid JSON in metadata document. 347 350 #[derive(Debug)] 348 351 struct JsonParseError { ··· 360 363 impl std::error::Error for JsonParseError {} 361 364 362 365 impl miette::Diagnostic for JsonParseError { 366 + fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> { 367 + Some(Box::new("oauth_client::discovery::metadata_is_json")) 368 + } 369 + 363 370 fn source_code(&self) -> Option<&(dyn miette::SourceCode + 'static)> { 364 371 Some(&self.source) 365 372 }
+29 -27
tests/common/mod.rs
··· 67 67 /// Fake HTTP client for identity resolution tests. 68 68 /// 69 69 /// Seeded with responses per URL. Panics if a URL is requested that wasn't seeded, 70 - /// to catch test-author mistakes. Supports both `get_bytes` and 71 - /// `get_bytes_with_content_type` with optional content-type headers. 70 + /// to catch test-author mistakes. Tests must explicitly seed every URL they expect 71 + /// the pipeline to fetch via `add_response` or `add_response_with_content_type`. 72 + /// Supports both `get_bytes` and `get_bytes_with_content_type` with optional 73 + /// content-type headers. 72 74 pub struct FakeHttpClient { 73 75 responses: FakeHttpClientResponses, 74 76 transport_errors: Arc<Mutex<HashSet<String>>>, ··· 131 133 }); 132 134 } 133 135 134 - // Look up the response. If not found, return 404 (mimics original behavior). 135 - Ok(self 136 - .responses 137 - .lock() 138 - .unwrap() 139 - .get(url_str) 140 - .map(|(status, body, _content_type)| (*status, body.clone())) 141 - .unwrap_or_else(|| (404, b"Not found".to_vec()))) 136 + // Look up the response. Panic if not found to catch test-author mistakes. 137 + match self.responses.lock().unwrap().get(url_str) { 138 + Some((status, body, _content_type)) => Ok((*status, body.clone())), 139 + None => panic!( 140 + "FakeHttpClient: no response seeded for URL {url_str}. Tests must seed every URL \ 141 + they expect the pipeline to fetch with add_response / add_response_with_content_type." 142 + ), 143 + } 142 144 } 143 145 144 146 async fn get_bytes_with_content_type( ··· 156 158 }); 157 159 } 158 160 159 - // Look up the response. If not found, return 404 (mimics original behavior). 160 - Ok(self 161 - .responses 162 - .lock() 163 - .unwrap() 164 - .get(url_str) 165 - .map(|(status, body, content_type)| (*status, body.clone(), content_type.clone())) 166 - .unwrap_or_else(|| (404, b"Not found".to_vec(), None))) 161 + // Look up the response. Panic if not found to catch test-author mistakes. 162 + match self.responses.lock().unwrap().get(url_str) { 163 + Some((status, body, content_type)) => Ok((*status, body.clone(), content_type.clone())), 164 + None => panic!( 165 + "FakeHttpClient: no response seeded for URL {url_str}. Tests must seed every URL \ 166 + they expect the pipeline to fetch with add_response / add_response_with_content_type." 167 + ), 168 + } 167 169 } 168 170 } 169 171 170 172 /// Fake DNS resolver for identity resolution tests. 171 173 /// 172 174 /// Seeded with records per domain. Panics if a domain is looked up that wasn't seeded, 173 - /// to catch test-author mistakes. 175 + /// to catch test-author mistakes. Tests must explicitly seed every domain they expect 176 + /// the pipeline to query via `add_records`. 174 177 pub struct FakeDnsResolver { 175 178 records: FakeDnsRecords, 176 179 } ··· 201 204 #[async_trait] 202 205 impl DnsResolver for FakeDnsResolver { 203 206 async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError> { 204 - self.records 205 - .lock() 206 - .unwrap() 207 - .get(name) 208 - .cloned() 209 - .ok_or_else(|| IdentityError::DnsLookupFailed { 210 - source: Box::new(IdentityError::InvalidHandle), 211 - }) 207 + match self.records.lock().unwrap().get(name).cloned() { 208 + Some(values) => Ok(values), 209 + None => panic!( 210 + "FakeDnsResolver: no records seeded for domain {name}. Tests must seed every \ 211 + domain they expect the pipeline to query with add_records." 212 + ), 213 + } 212 214 } 213 215 } 214 216
+39 -1
tests/labeler_identity.rs
··· 44 44 let did_json = include_bytes!("fixtures/labeler/identity/healthy_plc/did.json").to_vec(); 45 45 let labeler_record_json = 46 46 include_bytes!("fixtures/labeler/identity/healthy_plc/labeler_record.json").to_vec(); 47 + let plc_audit_log = 48 + include_bytes!("fixtures/identity/plc_audit_log_with_rotation.json").to_vec(); 47 49 48 50 let http = common::FakeHttpClient::new(); 49 51 let dns = common::FakeDnsResolver::new(); ··· 59 61 http.add_response(&Url::parse("https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=did:plc:test123456789abcdefghijklmnop&collection=app.bsky.labeler.service&rkey=self").unwrap(), 60 62 200, 61 63 labeler_record_json, 64 + ); 65 + 66 + // Mock the PLC audit log fetch for crypto stage. 67 + http.add_response( 68 + &Url::parse("https://plc.directory/did:plc:test123456789abcdefghijklmnop/log/audit") 69 + .unwrap(), 70 + 200, 71 + plc_audit_log, 62 72 ); 63 73 64 74 let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); ··· 176 186 http.add_response(&Url::parse("https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=did:plc:test123456789abcdefghijklmnop&collection=app.bsky.labeler.service&rkey=self").unwrap(), 177 187 200, 178 188 labeler_record_json, 189 + ); 190 + 191 + // Mock the PLC audit log fetch for crypto stage. 192 + let plc_audit_log = 193 + include_bytes!("fixtures/identity/plc_audit_log_with_rotation.json").to_vec(); 194 + http.add_response( 195 + &Url::parse("https://plc.directory/did:plc:test123456789abcdefghijklmnop/log/audit") 196 + .unwrap(), 197 + 200, 198 + plc_audit_log, 179 199 ); 180 200 181 201 let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); ··· 246 266 let http = common::FakeHttpClient::new(); 247 267 let dns = common::FakeDnsResolver::new(); 248 268 249 - // Don't add any response for PLC directory or DNS resolver - causes network error. 269 + // Seed DNS with a valid DID record. 270 + dns.add_records( 271 + "_atproto.alice.test", 272 + vec![ 273 + "v=atproto-did did:plc:z57g7ic4jcjt5y4yupjs6xgtqmv3v6nrwz2f3zzw3lc52r7yq6dq" 274 + .to_string(), 275 + ], 276 + ); 277 + 278 + // Add transport error for the PLC directory to simulate unreachability. 279 + http.add_transport_error( 280 + &Url::parse( 281 + "https://plc.directory/did:plc:z57g7ic4jcjt5y4yupjs6xgtqmv3v6nrwz2f3zzw3lc52r7yq6dq", 282 + ) 283 + .unwrap(), 284 + ); 285 + 286 + // Also add transport error for the .well-known fallback. 287 + http.add_transport_error(&Url::parse("https://alice.test/.well-known/atproto-did").unwrap()); 250 288 251 289 let target = parse_target("alice.test", None).expect("parse failed"); 252 290 let fake_tee = common::FakeRawHttpTee::new();
+25
tests/oauth_client_discovery.rs
··· 107 107 } 108 108 109 109 #[tokio::test] 110 + async fn https_not_json_with_content_type_produces_spec_violation_with_ct() { 111 + let http = common::FakeHttpClient::new(); 112 + let not_json = include_bytes!("fixtures/oauth_client/discovery/https_not_json/not_json.txt"); 113 + let url = Url::parse("https://client.example.com/metadata.json").unwrap(); 114 + http.add_response_with_content_type( 115 + &url, 116 + 200, 117 + not_json.to_vec(), 118 + Some("text/html; charset=utf-8".to_string()), 119 + ); 120 + 121 + let target = parse_target("https://client.example.com/metadata.json").expect("parse failed"); 122 + let opts = OauthClientOptions { 123 + http: &http, 124 + verbose: false, 125 + }; 126 + 127 + let report = run_pipeline(target, opts).await; 128 + assert_eq!(report.exit_code(), 1, "Expected spec violation exit code"); 129 + 130 + let rendered = render_report_to_string(&report); 131 + insta::assert_snapshot!(rendered); 132 + } 133 + 134 + #[tokio::test] 110 135 async fn loopback_root_produces_skip_rows() { 111 136 let http = common::FakeHttpClient::new(); 112 137
+6 -8
tests/snapshots/labeler_identity__did_plc_direct_happy_path.snap
··· 1 1 --- 2 2 source: tests/labeler_identity.rs 3 + assertion_line: 214 3 4 expression: rendered 4 5 --- 5 6 Target: did:plc:test123456789abcdefghijklmnop ··· 32 33 33 34 × failed to canonicalize label at://did:plc:healthy/app.bsky.feed.post/abc for signing 34 35 ╰─▶ Label is missing a 'sig' field 35 - [NET] PLC history fetch failed 36 - labeler::crypto::plc_history_fetch_network_error 36 + [FAIL] Some labels could not be verified against any key (tried 3 key id(s)) 37 + labeler::crypto::multi_key_verification_failed 37 38 38 - × failed to fetch PLC audit log for did:plc:test123456789abcdefghijklmnop: DID resolution failed with status 404 39 - [FAIL] Label signature verification failed 40 - labeler::crypto::label_verification_failed_no_history 41 - 42 - × label at://did:plc:healthy/app.bsky.feed.post/abc failed verification against current key "#atproto_label" and PLC history could not be consulted 39 + × some labels could not be verified against any of the 3 tried key id(s): ["zQ3shVc2UkAfJCdc1TR8E66J85h48P43r93q8jGPkPpjF9Ef9", "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7y", 40 + │ "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7z"] 43 41 44 - Summary: 12 passed, 2 failed (spec), 1 network errors, 1 advisories, 2 skipped. Exit code: 1 42 + Summary: 12 passed, 2 failed (spec), 0 network errors, 1 advisories, 2 skipped. Exit code: 1
+6 -8
tests/snapshots/labeler_identity__healthy_plc_renders_all_ok.snap
··· 1 1 --- 2 2 source: tests/labeler_identity.rs 3 + assertion_line: 89 3 4 expression: rendered 4 5 --- 5 6 Target: did:plc:test123456789abcdefghijklmnop ··· 32 33 33 34 × failed to canonicalize label at://did:plc:healthy/app.bsky.feed.post/abc for signing 34 35 ╰─▶ Label is missing a 'sig' field 35 - [NET] PLC history fetch failed 36 - labeler::crypto::plc_history_fetch_network_error 36 + [FAIL] Some labels could not be verified against any key (tried 3 key id(s)) 37 + labeler::crypto::multi_key_verification_failed 37 38 38 - × failed to fetch PLC audit log for did:plc:test123456789abcdefghijklmnop: DID resolution failed with status 404 39 - [FAIL] Label signature verification failed 40 - labeler::crypto::label_verification_failed_no_history 41 - 42 - × label at://did:plc:healthy/app.bsky.feed.post/abc failed verification against current key "#atproto_label" and PLC history could not be consulted 39 + × some labels could not be verified against any of the 3 tried key id(s): ["zQ3shVc2UkAfJCdc1TR8E66J85h48P43r93q8jGPkPpjF9Ef9", "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7y", 40 + │ "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7z"] 43 41 44 - Summary: 12 passed, 2 failed (spec), 1 network errors, 1 advisories, 2 skipped. Exit code: 1 42 + Summary: 12 passed, 2 failed (spec), 0 network errors, 1 advisories, 2 skipped. Exit code: 1
+3
tests/snapshots/oauth_client_discovery__https_404_produces_network_error.snap
··· 1 1 --- 2 2 source: tests/oauth_client_discovery.rs 3 + assertion_line: 64 3 4 expression: rendered 4 5 --- 5 6 Target: https://client.example.com/missing.json ··· 8 9 == Discovery == 9 10 [OK] Client ID well-formed 10 11 [NET] Metadata document unreachable 12 + oauth_client::discovery::metadata_document_fetchable 13 + 11 14 × Metadata document fetch returned HTTP 404: https://client.example.com/missing.json 12 15 [SKIP] Metadata is valid JSON — blocked by oauth_client::discovery::metadata_document_fetchable 13 16
+3
tests/snapshots/oauth_client_discovery__https_not_json_produces_spec_violation.snap
··· 1 1 --- 2 2 source: tests/oauth_client_discovery.rs 3 + assertion_line: 106 3 4 expression: rendered 4 5 --- 5 6 Target: https://client.example.com/metadata.json ··· 9 10 [OK] Client ID well-formed 10 11 [OK] Metadata document fetchable 11 12 [FAIL] Metadata is not valid JSON 13 + oauth_client::discovery::metadata_is_json 14 + 12 15 × response body is not valid JSON (content-type: <unknown>) 13 16 ╭─[metadata document (content-type: <unknown>):1:2] 14 17 1 │ not valid json at all
+21
tests/snapshots/oauth_client_discovery__https_not_json_with_content_type_produces_spec_violation_with_ct.snap
··· 1 + --- 2 + source: tests/oauth_client_discovery.rs 3 + assertion_line: 131 4 + expression: rendered 5 + --- 6 + Target: https://client.example.com/metadata.json 7 + elapsed: 0ms 8 + 9 + == Discovery == 10 + [OK] Client ID well-formed 11 + [OK] Metadata document fetchable 12 + [FAIL] Metadata is not valid JSON 13 + oauth_client::discovery::metadata_is_json 14 + 15 + × response body is not valid JSON (content-type: text/html; charset=utf-8) 16 + ╭─[metadata document (content-type: text/html; charset=utf-8):1:2] 17 + 1 │ not valid json at all 18 + · ─ 19 + ╰──── 20 + 21 + Summary: 2 passed, 1 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 1
+4 -1
tests/snapshots/oauth_client_discovery__https_unreachable_produces_network_error.snap
··· 1 1 --- 2 2 source: tests/oauth_client_discovery.rs 3 + assertion_line: 83 3 4 expression: rendered 4 5 --- 5 6 Target: https://client.example.com/metadata.json ··· 8 9 == Discovery == 9 10 [OK] Client ID well-formed 10 11 [NET] Metadata document unreachable 11 - × Failed to fetch metadata from https://client.example.com/metadata.json: DID resolution failed with status 0 12 + oauth_client::discovery::metadata_document_fetchable 13 + 14 + × Failed to fetch metadata from https://client.example.com/metadata.json: connection refused 12 15 [SKIP] Metadata is valid JSON — blocked by oauth_client::discovery::metadata_document_fetchable 13 16 14 17 Summary: 1 passed, 0 failed (spec), 1 network errors, 0 advisories, 1 skipped. Exit code: 2