release/1.3.1
rs 267 lines 8.57 KB
Raw
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 }