//! DCR tests (todo 30) against a `wiremock` fake AS — never a live server. //! //! All tokens and secrets below are synthetic test values. use std::{fs, path::PathBuf}; use provision::cli_driver::DEMO_POST_LOGOUT_URI; use provision::dcr::{ ClientRecord, POST_LOGOUT_NOT_ATTACHED_NOTE, PostLogoutAttach, PostLogoutOutcome, ReconcileOutcome, RegisterInputs, Registration, load, persist, reconcile, register, }; use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{body_json, header, method, path}, }; const FIRST_IAT: &str = "iat-first-use-only"; const FRESH_IAT: &str = "iat-fresh-second-use-only"; fn inputs(issuer: &str) -> RegisterInputs { RegisterInputs { issuer: issuer.to_owned(), ..RegisterInputs::default() } } fn expected_body() -> serde_json::Value { serde_json::json!({ "client_name": "oauth2-wasm-demo", "token_endpoint_auth_method": "client_secret_basic", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "redirect_uris": ["http://127.0.0.1:3000/callback"], "scope": "demo.read", "dpop_bound_access_tokens": true, }) } fn registration_response(server: &MockServer) -> serde_json::Value { serde_json::json!({ "client_id": "client-1", "client_secret": "secret-1", "registration_access_token": "rat-1", "registration_client_uri": format!("{}/register/client-1", server.uri()), "redirect_uris": ["http://127.0.0.1:3000/callback"], "client_name": "oauth2-wasm-demo", "token_endpoint_auth_method": "client_secret_basic", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scope": "demo.read", "dpop_bound_access_tokens": true, "client_id_issued_at": 1_700_000_000, }) } fn test_client() -> reqwest::Client { oauth_core::discovery::build_client().expect("test http client") } /// Install `fake-d-oauth.sh` executable in a fresh tempdir; return the tempdir, /// CLI path, and state dir. fn install_fake_cli() -> (tempfile::TempDir, PathBuf, PathBuf) { let dir = tempfile::tempdir().expect("tempdir"); let cli = dir.path().join("fake-d-oauth.sh"); let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fake-d-oauth.sh"); let script = fs::read_to_string(&source).expect("read fixture"); fs::write(&cli, script).expect("write fake cli"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; fs::set_permissions(&cli, fs::Permissions::from_mode(0o755)).expect("chmod fake cli"); } let state = dir.path().join("state"); fs::create_dir(&state).expect("state dir"); (dir, cli, state) } /// Write a `.env` file pointing the fake CLI at `state`. fn write_attach_env(dir: &tempfile::TempDir, state: &std::path::Path) -> PathBuf { let path = dir.path().join("attach.env"); fs::write(&path, format!("FAKE_STATE_DIR={}\n", state.display())).expect("write env file"); path } /// Attach config wired to the fake CLI. fn attach_config(cli: PathBuf, env_file: PathBuf) -> PostLogoutAttach { PostLogoutAttach { cli: Some(cli), env_file: Some(env_file), uri: DEMO_POST_LOGOUT_URI.to_owned(), } } /// The argv line the fake CLI records for the attach step. fn attach_line(client_id: &str) -> String { format!( "client attach-redirect-uri --client-id {client_id} --uri {DEMO_POST_LOGOUT_URI} --kind post_logout" ) } #[cfg(unix)] fn assert_0600(path: &std::path::Path) { use std::os::unix::fs::PermissionsExt as _; let mode = fs::metadata(path).expect("metadata").permissions().mode() & 0o777; assert_eq!(mode, 0o600, "client record must be owner-only"); } #[tokio::test] async fn dcr_registers_and_persists_0600() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/register")) .and(header( "authorization", format!("Bearer {FIRST_IAT}").as_str(), )) .and(body_json(expected_body())) .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server))) .mount(&server) .await; let dir = tempfile::tempdir().expect("tempdir"); let out = dir.path().join("client.json"); let record = register(&test_client(), &inputs(&server.uri()), FIRST_IAT) .await .expect("register"); assert_eq!(record.client_id, "client-1"); assert_eq!( record.registration_client_uri, format!("{}/register/client-1", server.uri()) ); persist(&record, &out).expect("persist"); #[cfg(unix)] assert_0600(&out); let stored = load(&out).expect("reload"); assert_eq!(stored, record); // The persisted file carries the minted credentials only — never the IAT. let raw = fs::read_to_string(&out).expect("read record"); assert!(!raw.contains(FIRST_IAT)); // The redacted Debug never leaks the secret or the management token. let debug = format!("{record:?}"); assert!(!debug.contains("secret-1")); assert!(!debug.contains("rat-1")); } #[tokio::test] async fn dcr_rerun_with_stale_record_reregisters_with_fresh_iat() { let server = MockServer::start().await; let stale_uri = format!("{}/register/stale-client", server.uri()); Mock::given(method("GET")) .and(path("/register/stale-client")) .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ "error": "invalid_client", }))) .mount(&server) .await; Mock::given(method("POST")) .and(path("/register")) .and(header( "authorization", format!("Bearer {FRESH_IAT}").as_str(), )) .and(body_json(expected_body())) .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server))) .mount(&server) .await; let dir = tempfile::tempdir().expect("tempdir"); let out = dir.path().join("client.json"); persist( &ClientRecord { client_id: "stale-client".to_owned(), client_secret: "stale-secret".to_owned(), registration_access_token: "stale-rat".to_owned(), registration_client_uri: stale_uri, }, &out, ) .expect("persist stale record"); let outcome = reconcile(&test_client(), &inputs(&server.uri()), &out, |status| { assert_eq!(status, 401); Ok(FRESH_IAT.to_owned()) }) .await .expect("reconcile"); assert_eq!(outcome, ReconcileOutcome::ReRegistered); let stored = load(&out).expect("reload"); assert_eq!(stored.client_id, "client-1"); assert!( !fs::read_to_string(&out) .expect("read") .contains("stale-secret") ); #[cfg(unix)] assert_0600(&out); } #[tokio::test] async fn dcr_live_record_is_kept_without_reregistering() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/register/client-1")) .and(header("authorization", "Bearer rat-1")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "client_id": "client-1", }))) .mount(&server) .await; let dir = tempfile::tempdir().expect("tempdir"); let out = dir.path().join("client.json"); let before = ClientRecord { client_id: "client-1".to_owned(), client_secret: "secret-1".to_owned(), registration_access_token: "rat-1".to_owned(), registration_client_uri: format!("{}/register/client-1", server.uri()), }; persist(&before, &out).expect("persist"); let outcome = reconcile(&test_client(), &inputs(&server.uri()), &out, |_| { panic!("a live record must not mint a fresh IAT"); }) .await .expect("reconcile"); assert_eq!(outcome, ReconcileOutcome::Kept); assert_eq!(load(&out).expect("reload"), before); } #[tokio::test] async fn dcr_invalid_redirect_uri_prints_remediation() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/register")) .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ "error": "invalid_redirect_uri", "error_description": "redirect URI is not in the catalog", }))) .mount(&server) .await; match register(&test_client(), &inputs(&server.uri()), FIRST_IAT).await { Err(provision::dcr::DcrError::Rejected { message, code, status, }) => { assert_eq!(status, 400); assert_eq!(code.as_deref(), Some("invalid_redirect_uri")); assert!( message.contains("dcr_redirect_policy=catalog"), "remediation: {message}" ); assert!( message.contains("catalog_loopback"), "remediation: {message}" ); } other => panic!("expected an invalid_redirect_uri rejection, got {other:?}"), } } #[tokio::test] async fn dcr_stale_record_without_iat_source_fails_typed() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/register/stale-client")) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; let dir = tempfile::tempdir().expect("tempdir"); let out = dir.path().join("client.json"); persist( &ClientRecord { client_id: "stale-client".to_owned(), client_secret: "stale-secret".to_owned(), registration_access_token: "stale-rat".to_owned(), registration_client_uri: format!("{}/register/stale-client", server.uri()), }, &out, ) .expect("persist stale record"); match reconcile(&test_client(), &inputs(&server.uri()), &out, |status| { Err(provision::dcr::DcrError::FreshIatRequired { status }) }) .await { Err(provision::dcr::DcrError::FreshIatRequired { status }) => assert_eq!(status, 404), other => panic!("expected FreshIatRequired, got {other:?}"), } } #[tokio::test] async fn dcr_register_then_attaches_post_logout() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/register")) .and(header( "authorization", format!("Bearer {FIRST_IAT}").as_str(), )) .and(body_json(expected_body())) .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server))) .mount(&server) .await; let (dir, cli, state) = install_fake_cli(); let env_file = write_attach_env(&dir, &state); let out = dir.path().join("client.json"); let reg_inputs = inputs(&server.uri()); let attach = attach_config(cli, env_file); let registration = Registration { inputs: ®_inputs, out: &out, attach: &attach, }; let (record, outcome) = registration .register_and_attach(&test_client(), FIRST_IAT) .await .expect("register then attach"); assert_eq!(record.client_id, "client-1"); assert_eq!(outcome, PostLogoutOutcome::Attached); assert!(out.exists(), "the record must be persisted before attach"); let log = fs::read_to_string(state.join("call.log")).expect("call log"); assert_eq!(log.lines().last(), Some(attach_line("client-1").as_str())); let _ = dir; } #[tokio::test] async fn dcr_reconcile_reregister_then_attaches_post_logout() { let server = MockServer::start().await; let stale_uri = format!("{}/register/stale-client", server.uri()); Mock::given(method("GET")) .and(path("/register/stale-client")) .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ "error": "invalid_client", }))) .mount(&server) .await; Mock::given(method("POST")) .and(path("/register")) .and(header( "authorization", format!("Bearer {FRESH_IAT}").as_str(), )) .and(body_json(expected_body())) .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server))) .mount(&server) .await; let (dir, cli, state) = install_fake_cli(); let env_file = write_attach_env(&dir, &state); let out = dir.path().join("client.json"); persist( &ClientRecord { client_id: "stale-client".to_owned(), client_secret: "stale-secret".to_owned(), registration_access_token: "stale-rat".to_owned(), registration_client_uri: stale_uri, }, &out, ) .expect("persist stale record"); let reg_inputs = inputs(&server.uri()); let attach = attach_config(cli, env_file); let registration = Registration { inputs: ®_inputs, out: &out, attach: &attach, }; let (outcome, attached) = registration .reconcile_and_attach(&test_client(), |status| { assert_eq!(status, 401); Ok(FRESH_IAT.to_owned()) }) .await .expect("reconcile then attach"); assert_eq!(outcome, ReconcileOutcome::ReRegistered); assert_eq!(attached, Some(PostLogoutOutcome::Attached)); assert_eq!(load(&out).expect("reload").client_id, "client-1"); let log = fs::read_to_string(state.join("call.log")).expect("call log"); assert!( log.lines().any(|line| line == attach_line("client-1")), "attach call missing from log: {log}" ); let _ = dir; } #[tokio::test] async fn dcr_live_record_is_kept_without_attaching() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/register/client-1")) .and(header("authorization", "Bearer rat-1")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "client_id": "client-1", }))) .mount(&server) .await; let (dir, cli, state) = install_fake_cli(); let env_file = write_attach_env(&dir, &state); let out = dir.path().join("client.json"); persist( &ClientRecord { client_id: "client-1".to_owned(), client_secret: "secret-1".to_owned(), registration_access_token: "rat-1".to_owned(), registration_client_uri: format!("{}/register/client-1", server.uri()), }, &out, ) .expect("persist live record"); let reg_inputs = inputs(&server.uri()); let attach = attach_config(cli, env_file); let registration = Registration { inputs: ®_inputs, out: &out, attach: &attach, }; let (outcome, attached) = registration .reconcile_and_attach(&test_client(), |_| { panic!("a live record must not mint a fresh IAT") }) .await .expect("reconcile"); assert_eq!(outcome, ReconcileOutcome::Kept); assert_eq!(attached, None); assert!( !state.join("call.log").exists(), "a live record must not trigger the attach step" ); let _ = dir; } #[test] fn dcr_attach_skipped_without_cli_is_typed() { let skip = PostLogoutAttach { cli: None, env_file: None, uri: DEMO_POST_LOGOUT_URI.to_owned(), }; assert_eq!( skip.apply("client-1").expect("skip is not an error"), PostLogoutOutcome::SkippedNoCli ); // Only one of the two CLI paths is not enough to attach. let partial = PostLogoutAttach { cli: Some(PathBuf::from("/bin/false")), env_file: None, uri: DEMO_POST_LOGOUT_URI.to_owned(), }; assert_eq!( partial.apply("client-1").expect("skip is not an error"), PostLogoutOutcome::SkippedNoCli ); assert!(!POST_LOGOUT_NOT_ATTACHED_NOTE.is_empty()); }