CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

test(oauth-client): add discovery stage integration tests with fixtures and snapshots

Add comprehensive integration tests for the OAuth client discovery stage with
8 test cases covering:

1. https_confidential_happy_discovery - HTTPS with valid JSON metadata (AC1.1)
2. https_404_produces_network_error - HTTPS with 404 status (AC1.5)
3. https_unreachable_produces_network_error - HTTPS with transport error (AC1.5)
4. https_not_json_produces_spec_violation - HTTPS with invalid JSON (AC1.6)
5. loopback_root_produces_skip_rows - Loopback http://localhost (AC1.2, AC1.7)
6. loopback_with_port_produces_same_skip_rows - Loopback with port (AC1.7)
7. loopback_127_0_0_1 - IPv4 loopback (AC1.3)
8. https_happy_body_flow_into_facts - Direct discovery stage call for facts shape

Fixtures:
- tests/fixtures/oauth_client/discovery/https_confidential_happy/metadata.json
- tests/fixtures/oauth_client/discovery/https_404/.gitkeep
- tests/fixtures/oauth_client/discovery/https_not_json/not_json.txt
- tests/fixtures/oauth_client/discovery/loopback_root/.gitkeep
- tests/fixtures/oauth_client/discovery/loopback_with_path/.gitkeep

Snapshots:
Created and accepted 7 insta snapshots pinning the rendered report output for
each test case, verifying check IDs, status glyphs, and exit codes match
expectations for all discovery outcomes.

All tests pass with accepted snapshots.

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

+321 -12
+10 -12
tests/common/mod.rs
··· 124 124 125 125 // Check for transport error first. 126 126 if self.transport_errors.lock().unwrap().contains(url_str) { 127 - return Err(IdentityError::HttpTransport( 128 - reqwest::Client::new() 129 - .get(url.as_str()) 130 - .build() 131 - .unwrap_err(), 132 - )); 127 + // Simulate a connection refused error. 128 + return Err(IdentityError::DidResolutionFailed { 129 + status: 0, 130 + body: "connection refused".to_string(), 131 + }); 133 132 } 134 133 135 134 // Look up the response. If not found, return 404 (mimics original behavior). ··· 150 149 151 150 // Check for transport error first. 152 151 if self.transport_errors.lock().unwrap().contains(url_str) { 153 - return Err(IdentityError::HttpTransport( 154 - reqwest::Client::new() 155 - .get(url.as_str()) 156 - .build() 157 - .unwrap_err(), 158 - )); 152 + // Simulate a connection refused error. 153 + return Err(IdentityError::DidResolutionFailed { 154 + status: 0, 155 + body: "connection refused".to_string(), 156 + }); 159 157 } 160 158 161 159 // Look up the response. If not found, return 404 (mimics original behavior).
tests/fixtures/oauth_client/discovery/https_404/.gitkeep

This is a binary file and will not be displayed.

+13
tests/fixtures/oauth_client/discovery/https_confidential_happy/metadata.json
··· 1 + { 2 + "client_id": "https://client.example.com/metadata.json", 3 + "application_type": "web", 4 + "redirect_uris": ["http://localhost/callback"], 5 + "grant_types": ["authorization_code"], 6 + "response_types": ["code"], 7 + "scope": "openid", 8 + "dpop_bound_access_tokens": false, 9 + "token_endpoint_auth_method": "private_key_jwt", 10 + "jwks": { 11 + "keys": [] 12 + } 13 + }
+1
tests/fixtures/oauth_client/discovery/https_not_json/not_json.txt
··· 1 + not valid json at all
tests/fixtures/oauth_client/discovery/loopback_root/.gitkeep

This is a binary file and will not be displayed.

tests/fixtures/oauth_client/discovery/loopback_with_path/.gitkeep

This is a binary file and will not be displayed.

+199
tests/oauth_client_discovery.rs
··· 1 + //! Integration tests for the oauth client discovery stage using snapshot tests. 2 + 3 + mod common; 4 + 5 + use atproto_devtool::commands::test::oauth::client::pipeline::discovery; 6 + use atproto_devtool::commands::test::oauth::client::pipeline::{ 7 + OauthClientOptions, OauthClientReport, parse_target, run_pipeline, 8 + }; 9 + use atproto_devtool::common::report::RenderConfig; 10 + use url::Url; 11 + 12 + /// Helper to render a report to a string. 13 + fn render_report_to_string(report: &OauthClientReport) -> String { 14 + let mut buf = Vec::new(); 15 + report 16 + .render(&mut buf, &RenderConfig { no_color: true }) 17 + .expect("render failed"); 18 + String::from_utf8(buf).expect("invalid utf-8") 19 + } 20 + 21 + #[tokio::test] 22 + async fn https_confidential_happy_discovery() { 23 + let http = common::FakeHttpClient::new(); 24 + let metadata = 25 + include_bytes!("fixtures/oauth_client/discovery/https_confidential_happy/metadata.json"); 26 + http.add_response( 27 + &Url::parse("https://client.example.com/metadata.json").unwrap(), 28 + 200, 29 + metadata.to_vec(), 30 + ); 31 + 32 + let target = parse_target("https://client.example.com/metadata.json").expect("parse failed"); 33 + let opts = OauthClientOptions { 34 + http: &http, 35 + verbose: false, 36 + }; 37 + 38 + let report = run_pipeline(target, opts).await; 39 + assert_eq!(report.exit_code(), 0, "Expected all checks to pass"); 40 + 41 + let rendered = render_report_to_string(&report); 42 + insta::assert_snapshot!(rendered); 43 + } 44 + 45 + #[tokio::test] 46 + async fn https_404_produces_network_error() { 47 + let http = common::FakeHttpClient::new(); 48 + http.add_response( 49 + &Url::parse("https://client.example.com/missing.json").unwrap(), 50 + 404, 51 + b"".to_vec(), 52 + ); 53 + 54 + let target = parse_target("https://client.example.com/missing.json").expect("parse failed"); 55 + let opts = OauthClientOptions { 56 + http: &http, 57 + verbose: false, 58 + }; 59 + 60 + let report = run_pipeline(target, opts).await; 61 + assert_eq!(report.exit_code(), 2, "Expected network error exit code"); 62 + 63 + let rendered = render_report_to_string(&report); 64 + insta::assert_snapshot!(rendered); 65 + } 66 + 67 + #[tokio::test] 68 + async fn https_unreachable_produces_network_error() { 69 + let http = common::FakeHttpClient::new(); 70 + let url = Url::parse("https://client.example.com/metadata.json").unwrap(); 71 + http.add_transport_error(&url); 72 + 73 + let target = parse_target("https://client.example.com/metadata.json").expect("parse failed"); 74 + let opts = OauthClientOptions { 75 + http: &http, 76 + verbose: false, 77 + }; 78 + 79 + let report = run_pipeline(target, opts).await; 80 + assert_eq!(report.exit_code(), 2, "Expected network error exit code"); 81 + 82 + let rendered = render_report_to_string(&report); 83 + insta::assert_snapshot!(rendered); 84 + } 85 + 86 + #[tokio::test] 87 + async fn https_not_json_produces_spec_violation() { 88 + let http = common::FakeHttpClient::new(); 89 + let not_json = include_bytes!("fixtures/oauth_client/discovery/https_not_json/not_json.txt"); 90 + http.add_response( 91 + &Url::parse("https://client.example.com/metadata.json").unwrap(), 92 + 200, 93 + not_json.to_vec(), 94 + ); 95 + 96 + let target = parse_target("https://client.example.com/metadata.json").expect("parse failed"); 97 + let opts = OauthClientOptions { 98 + http: &http, 99 + verbose: false, 100 + }; 101 + 102 + let report = run_pipeline(target, opts).await; 103 + assert_eq!(report.exit_code(), 1, "Expected spec violation exit code"); 104 + 105 + let rendered = render_report_to_string(&report); 106 + insta::assert_snapshot!(rendered); 107 + } 108 + 109 + #[tokio::test] 110 + async fn loopback_root_produces_skip_rows() { 111 + let http = common::FakeHttpClient::new(); 112 + 113 + let target = parse_target("http://localhost").expect("parse failed"); 114 + let opts = OauthClientOptions { 115 + http: &http, 116 + verbose: false, 117 + }; 118 + 119 + let report = run_pipeline(target, opts).await; 120 + assert_eq!(report.exit_code(), 0, "Expected success for loopback"); 121 + 122 + let rendered = render_report_to_string(&report); 123 + insta::assert_snapshot!(rendered); 124 + } 125 + 126 + #[tokio::test] 127 + async fn loopback_with_port_produces_same_skip_rows() { 128 + let http = common::FakeHttpClient::new(); 129 + 130 + let target = parse_target("http://localhost:8080/client.json").expect("parse failed"); 131 + let opts = OauthClientOptions { 132 + http: &http, 133 + verbose: false, 134 + }; 135 + 136 + let report = run_pipeline(target, opts).await; 137 + assert_eq!(report.exit_code(), 0, "Expected success for loopback"); 138 + 139 + let rendered = render_report_to_string(&report); 140 + insta::assert_snapshot!(rendered); 141 + } 142 + 143 + #[tokio::test] 144 + async fn loopback_127_0_0_1() { 145 + let http = common::FakeHttpClient::new(); 146 + 147 + let target = parse_target("http://127.0.0.1:3000/").expect("parse failed"); 148 + let opts = OauthClientOptions { 149 + http: &http, 150 + verbose: false, 151 + }; 152 + 153 + let report = run_pipeline(target, opts).await; 154 + assert_eq!(report.exit_code(), 0, "Expected success for loopback"); 155 + 156 + let rendered = render_report_to_string(&report); 157 + insta::assert_snapshot!(rendered); 158 + } 159 + 160 + #[tokio::test] 161 + async fn https_happy_body_flow_into_facts() { 162 + let http = common::FakeHttpClient::new(); 163 + let metadata = 164 + include_bytes!("fixtures/oauth_client/discovery/https_confidential_happy/metadata.json"); 165 + let url = Url::parse("https://client.example.com/metadata.json").unwrap(); 166 + http.add_response(&url, 200, metadata.to_vec()); 167 + 168 + let target = parse_target("https://client.example.com/metadata.json").expect("parse failed"); 169 + 170 + // Directly call the discovery stage to inspect facts. 171 + let output = discovery::run(&target, &http).await; 172 + 173 + // Verify facts are present. 174 + assert!( 175 + output.facts.is_some(), 176 + "Expected discovery facts to be populated" 177 + ); 178 + 179 + let facts = output.facts.unwrap(); 180 + assert_eq!( 181 + facts.kind, 182 + discovery::ClientIdKind::HttpsUrl, 183 + "Expected HTTPS client kind" 184 + ); 185 + 186 + // Verify the raw metadata is a Document variant. 187 + match &facts.raw_metadata { 188 + discovery::RawMetadata::Document { 189 + bytes, 190 + content_type, 191 + } => { 192 + assert!(!bytes.is_empty(), "Expected non-empty metadata bytes"); 193 + assert_eq!(content_type, &None, "Expected no content-type in this test"); 194 + } 195 + discovery::RawMetadata::Implicit { .. } => { 196 + panic!("Expected Document variant, got Implicit"); 197 + } 198 + } 199 + }
+14
tests/snapshots/oauth_client_discovery__https_404_produces_network_error.snap
··· 1 + --- 2 + source: tests/oauth_client_discovery.rs 3 + expression: rendered 4 + --- 5 + Target: https://client.example.com/missing.json 6 + elapsed: 0ms 7 + 8 + == Discovery == 9 + [OK] Client ID well-formed 10 + [NET] Metadata document unreachable 11 + × Metadata document fetch returned HTTP 404: https://client.example.com/missing.json 12 + [SKIP] Metadata is valid JSON — blocked by oauth_client::discovery::metadata_document_fetchable 13 + 14 + Summary: 1 passed, 0 failed (spec), 1 network errors, 0 advisories, 1 skipped. Exit code: 2
+13
tests/snapshots/oauth_client_discovery__https_confidential_happy_discovery.snap
··· 1 + --- 2 + source: tests/oauth_client_discovery.rs 3 + expression: rendered 4 + --- 5 + Target: https://client.example.com/metadata.json 6 + elapsed: 0ms 7 + 8 + == Discovery == 9 + [OK] Client ID well-formed 10 + [OK] Metadata document fetchable 11 + [OK] Metadata is valid JSON 12 + 13 + Summary: 3 passed, 0 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 0
+18
tests/snapshots/oauth_client_discovery__https_not_json_produces_spec_violation.snap
··· 1 + --- 2 + source: tests/oauth_client_discovery.rs 3 + expression: rendered 4 + --- 5 + Target: https://client.example.com/metadata.json 6 + elapsed: 0ms 7 + 8 + == Discovery == 9 + [OK] Client ID well-formed 10 + [OK] Metadata document fetchable 11 + [FAIL] Metadata is not valid JSON 12 + × response body is not valid JSON (content-type: <unknown>) 13 + ╭─[metadata document (content-type: <unknown>):1:2] 14 + 1 │ not valid json at all 15 + · ─ 16 + ╰──── 17 + 18 + Summary: 2 passed, 1 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 1
+14
tests/snapshots/oauth_client_discovery__https_unreachable_produces_network_error.snap
··· 1 + --- 2 + source: tests/oauth_client_discovery.rs 3 + expression: rendered 4 + --- 5 + Target: https://client.example.com/metadata.json 6 + elapsed: 0ms 7 + 8 + == Discovery == 9 + [OK] Client ID well-formed 10 + [NET] Metadata document unreachable 11 + × Failed to fetch metadata from https://client.example.com/metadata.json: DID resolution failed with status 0 12 + [SKIP] Metadata is valid JSON — blocked by oauth_client::discovery::metadata_document_fetchable 13 + 14 + Summary: 1 passed, 0 failed (spec), 1 network errors, 0 advisories, 1 skipped. Exit code: 2
+13
tests/snapshots/oauth_client_discovery__loopback_127_0_0_1.snap
··· 1 + --- 2 + source: tests/oauth_client_discovery.rs 3 + expression: rendered 4 + --- 5 + Target: http://127.0.0.1:3000/ 6 + elapsed: 0ms 7 + 8 + == Discovery == 9 + [OK] Client ID well-formed 10 + [SKIP] Metadata document fetchable — metadata is implicit for loopback clients 11 + [SKIP] Metadata is valid JSON — metadata is implicit for loopback clients 12 + 13 + Summary: 1 passed, 0 failed (spec), 0 network errors, 0 advisories, 2 skipped. Exit code: 0
+13
tests/snapshots/oauth_client_discovery__loopback_root_produces_skip_rows.snap
··· 1 + --- 2 + source: tests/oauth_client_discovery.rs 3 + expression: rendered 4 + --- 5 + Target: http://localhost/ 6 + elapsed: 0ms 7 + 8 + == Discovery == 9 + [OK] Client ID well-formed 10 + [SKIP] Metadata document fetchable — metadata is implicit for loopback clients 11 + [SKIP] Metadata is valid JSON — metadata is implicit for loopback clients 12 + 13 + Summary: 1 passed, 0 failed (spec), 0 network errors, 0 advisories, 2 skipped. Exit code: 0
+13
tests/snapshots/oauth_client_discovery__loopback_with_port_produces_same_skip_rows.snap
··· 1 + --- 2 + source: tests/oauth_client_discovery.rs 3 + expression: rendered 4 + --- 5 + Target: http://localhost:8080/client.json 6 + elapsed: 0ms 7 + 8 + == Discovery == 9 + [OK] Client ID well-formed 10 + [SKIP] Metadata document fetchable — metadata is implicit for loopback clients 11 + [SKIP] Metadata is valid JSON — metadata is implicit for loopback clients 12 + 13 + Summary: 1 passed, 0 failed (spec), 0 network errors, 0 advisories, 2 skipped. Exit code: 0