| 1 | //! The client against an in-process stand-in for onde-web's API. |
| 2 | |
| 3 | use std::sync::{Arc, Mutex}; |
| 4 | |
| 5 | use axum::extract::{Path, Query, State}; |
| 6 | use axum::http::{HeaderMap, StatusCode}; |
| 7 | use axum::routing::{get, patch, post}; |
| 8 | use axum::{Json, Router}; |
| 9 | use ed_onde_account::{AccessToken, Error, OndeAccount, OndeEnv, SignIn}; |
| 10 | use serde_json::{Value, json}; |
| 11 | use std::collections::HashMap; |
| 12 | |
| 13 | const TOKEN: &str = "jwt-123"; |
| 14 | |
| 15 | #[derive(Default)] |
| 16 | struct Mock { |
| 17 | apps: Vec<Value>, |
| 18 | /// An app another caller creates just before this server handles a `POST`. |
| 19 | rival: Option<Value>, |
| 20 | /// Every `(method, path, env)` the server saw. |
| 21 | calls: Vec<String>, |
| 22 | } |
| 23 | |
| 24 | type Shared = Arc<Mutex<Mock>>; |
| 25 | |
| 26 | fn app(id: &str, name: &str, status: &str) -> Value { |
| 27 | json!({ "id": id, "name": name, "status": status, "app_id": id, "app_secret": format!("sec-{id}") }) |
| 28 | } |
| 29 | |
| 30 | fn authorized(headers: &HeaderMap) -> bool { |
| 31 | headers |
| 32 | .get("authorization") |
| 33 | .and_then(|v| v.to_str().ok()) |
| 34 | .is_some_and(|v| v == format!("Bearer {TOKEN}")) |
| 35 | } |
| 36 | |
| 37 | async fn serve(apps: Vec<Value>) -> (OndeAccount, Shared) { |
| 38 | let state: Shared = Arc::new(Mutex::new(Mock { |
| 39 | apps, |
| 40 | rival: None, |
| 41 | calls: vec![], |
| 42 | })); |
| 43 | |
| 44 | async fn sign_in( |
| 45 | State(s): State<Shared>, |
| 46 | Query(q): Query<HashMap<String, String>>, |
| 47 | Json(body): Json<Value>, |
| 48 | ) -> (StatusCode, Json<Value>) { |
| 49 | s.lock() |
| 50 | .unwrap() |
| 51 | .calls |
| 52 | .push(format!("sign-in env={}", q["env"])); |
| 53 | if body["email"] == "wrong@x.io" { |
| 54 | return ( |
| 55 | StatusCode::UNAUTHORIZED, |
| 56 | Json(json!({ "message": "Invalid password." })), |
| 57 | ); |
| 58 | } |
| 59 | ( |
| 60 | StatusCode::OK, |
| 61 | Json(match body["email"].as_str().unwrap() { |
| 62 | "ready@x.io" => { |
| 63 | json!({ "status": "ready", "accessToken": format!("Bearer {TOKEN}") }) |
| 64 | } |
| 65 | "new@x.io" => json!({ "status": "notFound" }), |
| 66 | _ => json!({ "status": "incomplete", "errorCode": 100 }), |
| 67 | }), |
| 68 | ) |
| 69 | } |
| 70 | async fn me(headers: HeaderMap) -> (StatusCode, Json<Value>) { |
| 71 | if !authorized(&headers) { |
| 72 | return ( |
| 73 | StatusCode::UNAUTHORIZED, |
| 74 | Json(json!({ "error": "Unauthorized" })), |
| 75 | ); |
| 76 | } |
| 77 | ( |
| 78 | StatusCode::OK, |
| 79 | Json(json!({ "email": "ready@x.io", "name": "Ready", "can_create_gresiq_apps": true })), |
| 80 | ) |
| 81 | } |
| 82 | async fn list(State(s): State<Shared>, headers: HeaderMap) -> (StatusCode, Json<Value>) { |
| 83 | if !authorized(&headers) { |
| 84 | return (StatusCode::UNAUTHORIZED, Json(json!({}))); |
| 85 | } |
| 86 | ( |
| 87 | StatusCode::OK, |
| 88 | Json(Value::Array(s.lock().unwrap().apps.clone())), |
| 89 | ) |
| 90 | } |
| 91 | async fn create(State(s): State<Shared>, Json(body): Json<Value>) -> (StatusCode, Json<Value>) { |
| 92 | let mut s = s.lock().unwrap(); |
| 93 | s.calls.push("create".into()); |
| 94 | if let Some(rival) = s.rival.take() { |
| 95 | s.apps.push(rival); |
| 96 | } |
| 97 | let created = app( |
| 98 | "new-1", |
| 99 | body["gresiq_app"]["name"].as_str().unwrap(), |
| 100 | "provisioning", |
| 101 | ); |
| 102 | s.apps.push(created.clone()); |
| 103 | (StatusCode::CREATED, Json(created)) |
| 104 | } |
| 105 | async fn activate( |
| 106 | State(s): State<Shared>, |
| 107 | Path(id): Path<String>, |
| 108 | Json(body): Json<Value>, |
| 109 | ) -> Json<Value> { |
| 110 | let mut s = s.lock().unwrap(); |
| 111 | s.calls |
| 112 | .push(format!("activate {id} {}", body["gresiq_app"]["status"])); |
| 113 | let found = s.apps.iter_mut().find(|a| a["id"] == id).unwrap(); |
| 114 | found["status"] = json!("active"); |
| 115 | Json(found.clone()) |
| 116 | } |
| 117 | |
| 118 | let router = Router::new() |
| 119 | .route("/api/auth/sign-in", post(sign_in)) |
| 120 | .route("/api/auth/me", get(me)) |
| 121 | .route("/api/gresiq/apps", get(list).post(create)) |
| 122 | .route("/api/gresiq/apps/{id}", patch(activate)) |
| 123 | .with_state(state.clone()); |
| 124 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 125 | let addr = listener.local_addr().unwrap(); |
| 126 | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
| 127 | |
| 128 | let account = OndeAccount::with_base_url(&format!("http://{addr}")).unwrap(); |
| 129 | (account, state) |
| 130 | } |
| 131 | |
| 132 | #[tokio::test] |
| 133 | async fn sign_in_outcomes_and_token_normalization() { |
| 134 | let (account, state) = serve(vec![]).await; |
| 135 | |
| 136 | let SignIn::Ready(token) = account.sign_in("ready@x.io", "pw").await.unwrap() else { |
| 137 | panic!("expected ready"); |
| 138 | }; |
| 139 | assert_eq!(token.as_str(), TOKEN, "the Bearer prefix is stripped"); |
| 140 | assert_eq!(format!("{token:?}"), "AccessToken(<redacted>)"); |
| 141 | |
| 142 | assert_eq!( |
| 143 | account.sign_in("new@x.io", "pw").await.unwrap(), |
| 144 | SignIn::NotFound |
| 145 | ); |
| 146 | assert_eq!( |
| 147 | account.sign_in("unconfirmed@x.io", "pw").await.unwrap(), |
| 148 | SignIn::Incomplete { |
| 149 | error_code: Some(100) |
| 150 | } |
| 151 | ); |
| 152 | |
| 153 | assert!(matches!( |
| 154 | account.sign_in("wrong@x.io", "pw").await, |
| 155 | Err(Error::InvalidCredentials) |
| 156 | )); |
| 157 | |
| 158 | let dev = account.clone().with_env(OndeEnv::Development); |
| 159 | dev.sign_in("new@x.io", "pw").await.unwrap(); |
| 160 | let calls = state.lock().unwrap().calls.clone(); |
| 161 | assert_eq!(calls[0], "sign-in env=production"); |
| 162 | assert_eq!(calls[4], "sign-in env=development"); |
| 163 | } |
| 164 | |
| 165 | #[test] |
| 166 | fn access_token_strips_repeated_prefixes() { |
| 167 | assert_eq!(AccessToken::new(" bearer Bearer abc ").as_str(), "abc"); |
| 168 | assert_eq!(AccessToken::new("abc").as_str(), "abc"); |
| 169 | } |
| 170 | |
| 171 | #[tokio::test] |
| 172 | async fn me_reads_the_profile() { |
| 173 | let (account, _) = serve(vec![]).await; |
| 174 | let profile = account.me(&AccessToken::new(TOKEN)).await.unwrap(); |
| 175 | assert_eq!(profile.email, "ready@x.io"); |
| 176 | assert!(profile.can_create); |
| 177 | } |
| 178 | |
| 179 | #[tokio::test] |
| 180 | async fn ensure_key_reuses_an_active_app() { |
| 181 | let (account, state) = serve(vec![ |
| 182 | app("a1", "Other", "active"), |
| 183 | app("a2", "KaroKowe", "active"), |
| 184 | ]) |
| 185 | .await; |
| 186 | let key = account |
| 187 | .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") |
| 188 | .await |
| 189 | .unwrap(); |
| 190 | assert_eq!(key.as_str(), "a2:sec-a2"); |
| 191 | assert_eq!(format!("{key:?}"), "ApiKey(<redacted>)"); |
| 192 | assert!( |
| 193 | state.lock().unwrap().calls.is_empty(), |
| 194 | "nothing was created or patched" |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | #[tokio::test] |
| 199 | async fn ensure_key_activates_a_provisioning_app() { |
| 200 | let (account, state) = serve(vec![app("a1", "KaroKowe", "provisioning")]).await; |
| 201 | let key = account |
| 202 | .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") |
| 203 | .await |
| 204 | .unwrap(); |
| 205 | assert_eq!(key.as_str(), "a1:sec-a1"); |
| 206 | assert_eq!(state.lock().unwrap().calls, vec![r#"activate a1 "active""#]); |
| 207 | } |
| 208 | |
| 209 | #[tokio::test] |
| 210 | async fn ensure_key_creates_then_activates_when_none_exists() { |
| 211 | let (account, state) = serve(vec![app("a1", "Other", "active")]).await; |
| 212 | let token = AccessToken::new(TOKEN); |
| 213 | let key = account.ensure_key(&token, "KaroKowe").await.unwrap(); |
| 214 | assert_eq!(key.as_str(), "new-1:sec-new-1"); |
| 215 | assert_eq!( |
| 216 | state.lock().unwrap().calls, |
| 217 | vec!["create".to_owned(), r#"activate new-1 "active""#.to_owned()] |
| 218 | ); |
| 219 | // A second call finds it instead of creating another. |
| 220 | account.ensure_key(&token, "KaroKowe").await.unwrap(); |
| 221 | assert_eq!(state.lock().unwrap().calls.len(), 2); |
| 222 | } |
| 223 | |
| 224 | #[tokio::test] |
| 225 | async fn ensure_key_converges_when_a_create_races() { |
| 226 | let (account, state) = serve(vec![]).await; |
| 227 | // The other caller's create lands first, so its app has the lower id. |
| 228 | state.lock().unwrap().rival = Some(app("7", "KaroKowe", "provisioning")); |
| 229 | let key = account |
| 230 | .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") |
| 231 | .await |
| 232 | .unwrap(); |
| 233 | assert_eq!( |
| 234 | key.as_str(), |
| 235 | "7:sec-7", |
| 236 | "the oldest app wins, not the one just created" |
| 237 | ); |
| 238 | assert_eq!( |
| 239 | state.lock().unwrap().calls, |
| 240 | vec!["create".to_owned(), r#"activate 7 "active""#.to_owned()] |
| 241 | ); |
| 242 | } |
| 243 | |
| 244 | #[tokio::test] |
| 245 | async fn ensure_key_refuses_a_suspended_app() { |
| 246 | let (account, _) = serve(vec![app("a1", "KaroKowe", "suspended")]).await; |
| 247 | let err = account |
| 248 | .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") |
| 249 | .await |
| 250 | .unwrap_err(); |
| 251 | assert!(matches!(err, Error::Suspended(name) if name == "KaroKowe")); |
| 252 | } |
| 253 | |
| 254 | #[tokio::test] |
| 255 | async fn an_expired_token_is_unauthorized() { |
| 256 | let (account, _) = serve(vec![]).await; |
| 257 | let stale = AccessToken::new("expired"); |
| 258 | assert!(matches!(account.me(&stale).await, Err(Error::Unauthorized))); |
| 259 | assert!(matches!( |
| 260 | account.apps(&stale).await, |
| 261 | Err(Error::Unauthorized) |
| 262 | )); |
| 263 | assert!(matches!( |
| 264 | account.ensure_key(&stale, "KaroKowe").await, |
| 265 | Err(Error::Unauthorized) |
| 266 | )); |
| 267 | } |