1use uuid::Uuid;
15
16use crate::SessionSlot;
17use crate::protocol::{PendingAsk, SessionUpdate};
18use crate::workspace::Workspace;
19use forge_primitives::vault::{VaultAskAnswer, VaultAskEnding, VaultDraft};
20
21pub(crate) type ParkedVaultAsk =
24 (SessionSlot, VaultDraft, Option<tokio::sync::oneshot::Sender<VaultAskAnswer>>);
25
26impl Workspace {
27 pub(crate) fn register_vault_ask(
33 &self,
34 caller: &SessionSlot,
35 draft: VaultDraft,
36 needs_unlock: bool,
37 ) -> (Uuid, tokio::sync::oneshot::Receiver<VaultAskAnswer>, bool) {
38 let (sender, receiver) = tokio::sync::oneshot::channel();
39 let id = draft.id;
40 self.parked_vault_asks.lock().insert(id, (caller.clone(), draft.clone(), Some(sender)));
41 let stamped = draft.with_unlock_state(needs_unlock);
42 let answerable = self
43 .update_sender()
44 .send_answering(SessionUpdate::VaultAskPending { key: caller.clone(), draft: stamped });
45 if !answerable {
46 self.parked_vault_asks.lock().remove(&id);
49 let (_ignored_sender, dead_receiver) = tokio::sync::oneshot::channel();
50 return (id, dead_receiver, false);
51 }
52 self.note_ask(caller);
55 (id, receiver, true)
56 }
57
58 pub(crate) fn vault_ask_waiting(&self, id: Uuid, caller: &SessionSlot) -> bool {
61 self.parked_vault_asks.lock().get(&id).is_some_and(|(owner, _, _)| owner == caller)
62 }
63
64 pub(crate) fn resolve_vault_ask(
68 &self,
69 id: Uuid,
70 caller: &SessionSlot,
71 answer: VaultAskAnswer,
72 ) -> bool {
73 let mut asks = self.parked_vault_asks.lock();
74 let Some((owner, _, sender)) = asks.get_mut(&id) else { return false };
75 if owner != caller {
76 return false;
77 }
78 let Some(sender) = sender.take() else { return false };
79 drop(asks);
80 let _ = sender.send(answer);
81 true
82 }
83
84 pub(crate) fn settle_vault_ask(
88 &self,
89 id: Uuid,
90 caller: &SessionSlot,
91 ending: VaultAskEnding,
92 ) -> bool {
93 let mut asks = self.parked_vault_asks.lock();
94 if !asks.get(&id).is_some_and(|(owner, _, _)| owner == caller) {
95 return false;
96 }
97 let Some((owner, _, _)) = asks.remove(&id) else { return false };
98 drop(asks);
99 let _ =
100 self.update_sender().send(SessionUpdate::VaultAskResolved { key: owner, id, ending });
101 true
102 }
103
104 pub(crate) fn abandon_vault_asks(&self, slot: &SessionSlot) {
111 let ids: Vec<Uuid> = self
112 .parked_vault_asks
113 .lock()
114 .iter()
115 .filter(|(_, (owner, _, _))| owner == slot)
116 .map(|(id, _)| *id)
117 .collect();
118 for id in ids {
119 self.settle_vault_ask(id, slot, VaultAskEnding::Abandoned);
120 }
121 }
122
123 pub(crate) fn rebase_vault_asks(&self, needs_unlock: bool) {
130 let asks = self.parked_vault_asks.lock();
134 for (owner, draft, _) in asks.values() {
135 let _ = self.update_sender().send(SessionUpdate::VaultAskPending {
136 key: owner.clone(),
137 draft: draft.clone().with_unlock_state(needs_unlock),
138 });
139 }
140 }
141
142 pub(crate) fn vault_asks(&self, slot: &SessionSlot, needs_unlock: bool) -> Vec<PendingAsk> {
146 self.parked_vault_asks
147 .lock()
148 .values()
149 .filter(|(owner, _, _)| owner == slot)
150 .map(|(_, draft, _)| {
151 PendingAsk::Vault(Box::new(draft.clone().with_unlock_state(needs_unlock)))
152 })
153 .collect()
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use std::sync::Arc;
160
161 use super::*;
162 use crate::protocol::Command;
163 use forge_primitives::vault::{VaultAskDecision, VaultTool};
164
165 fn caller() -> SessionSlot {
166 SessionSlot::lead("Default", "forge")
167 }
168
169 fn a_draft() -> VaultDraft {
170 VaultDraft::new(VaultTool::Get, "read the password for 'bank'")
171 }
172
173 fn stub() -> (Arc<Workspace>, tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>) {
174 Workspace::testing_stub()
175 }
176
177 #[test]
179 fn a_call_is_addressed_to_the_session_that_asked() {
180 let (ws, mut rx) = stub();
181 let asked = SessionSlot::worker("Default", "forge", "vault-worker");
182 let _held = ws.register_vault_ask(&asked, a_draft(), false);
183
184 let mut addressed = Vec::new();
185 while let Ok(frame) = rx.try_recv() {
186 if let SessionUpdate::VaultAskPending { key, .. } =
187 std::sync::Arc::unwrap_or_clone(frame.update)
188 {
189 addressed.push(key);
190 }
191 }
192 assert_eq!(addressed, vec![asked], "the call is addressed to the worker that asked");
193 }
194
195 #[test]
199 fn the_record_read_restamps_the_lock_state() {
200 let (ws, _rx) = stub();
201 let draft = a_draft();
202 let (id, _held, _) = ws.register_vault_ask(&caller(), draft, true);
203
204 let asks = ws.vault_asks(&caller(), false);
205 assert!(
206 asks.iter().any(|waiting| matches!(
207 waiting,
208 PendingAsk::Vault(held) if held.id == id && !held.needs_unlock
209 )),
210 "the live session state wins over the park-time stamp",
211 );
212 assert!(ws.vault_asks(&SessionSlot::lead("Other", "project"), false).is_empty());
213 }
214
215 #[test]
219 fn the_record_read_reports_the_held_call() {
220 let (ws, _rx) = stub();
221 let (id, _held, _) = ws.register_vault_ask(&caller(), a_draft(), false);
222 assert!(
223 ws.pending_asks(&caller())
224 .iter()
225 .any(|ask| matches!(ask, PendingAsk::Vault(draft) if draft.id == id)),
226 "the record read carries the held vault call",
227 );
228 assert!(ws.pending_asks(&SessionSlot::lead("Other", "project")).is_empty());
229 }
230
231 #[test]
235 fn a_second_parked_call_rebases_when_the_first_unlocks() {
236 let (ws, _rx) = stub();
237 let (first, _held_first, _) = ws.register_vault_ask(&caller(), a_draft(), true);
238 let (second, _held_second, _) = ws.register_vault_ask(&caller(), a_draft(), true);
239
240 let locked = ws.vault_asks(&caller(), true);
241 assert_eq!(locked.len(), 2);
242 assert!(
243 locked.iter().all(|ask| matches!(
244 ask,
245 PendingAsk::Vault(draft) if draft.needs_unlock
246 )),
247 "both cards start locked",
248 );
249
250 let rebased = ws.vault_asks(&caller(), false);
251 assert_eq!(rebased.len(), 2);
252 assert!(
253 rebased.iter().all(|ask| matches!(
254 ask,
255 PendingAsk::Vault(draft) if !draft.needs_unlock
256 )),
257 "the live session state re-bases every other card",
258 );
259 for id in [first, second] {
260 assert!(
261 rebased.iter().any(|ask| matches!(
262 ask,
263 PendingAsk::Vault(draft) if draft.id == id
264 )),
265 "the re-base keeps the call's identity",
266 );
267 }
268 }
269
270 #[test]
273 fn unlocking_pushes_a_restamped_frame_for_the_other_cards() {
274 let (ws, mut rx) = stub();
275 let seat = caller();
276 let (answered, _held, _) = ws.register_vault_ask(&seat, a_draft(), true);
277 let (other, _other_held, _) = ws.register_vault_ask(&seat, a_draft(), true);
278 while rx.try_recv().is_ok() {}
279
280 ws.settle_vault_ask(answered, &seat, VaultAskEnding::Approved);
281 while rx.try_recv().is_ok() {}
282 ws.rebase_vault_asks(false);
283
284 let mut seen = Vec::new();
285 while let Ok(frame) = rx.try_recv() {
286 if let SessionUpdate::VaultAskPending { draft, .. } =
287 std::sync::Arc::unwrap_or_clone(frame.update)
288 {
289 seen.push(draft);
290 }
291 }
292 assert_eq!(seen.len(), 1, "only the card still parked is pushed");
293 assert_eq!(seen[0].id, other);
294 assert!(!seen[0].needs_unlock, "the unlock is stamped on the pushed draft");
295 }
296
297 #[test]
300 fn a_relock_pushes_a_restamped_frame() {
301 let (ws, mut rx) = stub();
302 let (id, _held, _) = ws.register_vault_ask(&caller(), a_draft(), false);
303 while rx.try_recv().is_ok() {}
304
305 ws.rebase_vault_asks(true);
306
307 let mut seen = Vec::new();
308 while let Ok(frame) = rx.try_recv() {
309 if let SessionUpdate::VaultAskPending { draft, .. } =
310 std::sync::Arc::unwrap_or_clone(frame.update)
311 {
312 seen.push(draft);
313 }
314 }
315 assert_eq!(seen.len(), 1);
316 assert_eq!(seen[0].id, id);
317 assert!(seen[0].needs_unlock, "the re-lock is stamped on the pushed draft");
318 }
319
320 #[test]
323 fn an_answer_resolves_the_waiter_exactly_once() {
324 let (ws, _rx) = stub();
325 let draft = a_draft();
326 let (id, mut held, _) = ws.register_vault_ask(&caller(), draft, false);
327
328 assert!(ws.vault_ask_waiting(id, &caller()));
329 let answer = VaultAskAnswer {
330 decision: VaultAskDecision::Approve,
331 master_password: None,
332 two_factor: None,
333 released: None,
334 };
335 assert!(ws.resolve_vault_ask(id, &caller(), answer.clone()));
336 assert_eq!(held.try_recv(), Ok(answer), "the approval reaches the blocked caller");
337 assert!(
338 !ws.resolve_vault_ask(
339 id,
340 &caller(),
341 VaultAskAnswer {
342 decision: VaultAskDecision::Decline,
343 master_password: None,
344 two_factor: None,
345 released: None,
346 }
347 ),
348 "one answer only",
349 );
350 assert!(ws.settle_vault_ask(id, &caller(), VaultAskEnding::Approved));
351 assert!(!ws.vault_ask_waiting(id, &caller()));
352 }
353
354 #[test]
356 fn another_seat_cannot_answer_this_call() {
357 let (ws, _rx) = stub();
358 let (id, _held, _) = ws.register_vault_ask(&caller(), a_draft(), false);
359 let elsewhere = SessionSlot::worker("Default", "forge", "someone-else");
360
361 assert!(!ws.resolve_vault_ask(
362 id,
363 &elsewhere,
364 VaultAskAnswer {
365 decision: VaultAskDecision::Approve,
366 master_password: None,
367 two_factor: None,
368 released: None,
369 }
370 ));
371 assert!(ws.vault_ask_waiting(id, &caller()), "the call is still the asker's");
372 }
373
374 #[tokio::test]
377 async fn a_call_with_no_ui_to_answer_it_fails_closed() {
378 let (ws, rx) = Workspace::testing_stub();
379 drop(rx);
380
381 let (_id, held, _) = ws.register_vault_ask(&caller(), a_draft(), false);
382
383 assert!(ws.parked_vault_asks.lock().is_empty(), "an unanswerable call is dropped");
384 assert!(held.await.is_err(), "a dead receiver reads as not-approved");
385 }
386
387 #[tokio::test]
389 async fn the_dock_answer_reaches_the_registry_through_dispatch() {
390 let (ws, _rx) = stub();
391 let (id, decision, _) = ws.register_vault_ask(&caller(), a_draft(), false);
392 let answer = VaultAskAnswer {
393 decision: VaultAskDecision::Approve,
394 master_password: None,
395 two_factor: None,
396 released: None,
397 };
398
399 ws.dispatch(Command::RespondVaultAsk { key: caller(), id, answer: answer.clone() })
400 .expect("the dock answer routes to the workspace");
401
402 assert_eq!(decision.await.expect("the held call answers"), answer);
403 }
404
405 #[tokio::test]
407 async fn a_stale_dock_answer_is_refused_with_the_reason() {
408 let (ws, _rx) = stub();
409 let (id, _decision, _) = ws.register_vault_ask(&caller(), a_draft(), false);
410 assert!(ws.resolve_vault_ask(
411 id,
412 &caller(),
413 VaultAskAnswer {
414 decision: VaultAskDecision::Approve,
415 master_password: None,
416 two_factor: None,
417 released: None,
418 }
419 ));
420
421 let refused = ws.dispatch(Command::RespondVaultAsk {
422 key: caller(),
423 id,
424 answer: VaultAskAnswer {
425 decision: VaultAskDecision::Approve,
426 master_password: None,
427 two_factor: None,
428 released: None,
429 },
430 });
431
432 assert!(
433 matches!(refused, Err(crate::protocol::DispatchError::NoVaultAskWaiting { .. })),
434 "a stale dock must report why the answer did not land: {refused:?}",
435 );
436 }
437
438 #[test]
440 fn a_session_teardown_abandons_the_calls_it_holds() {
441 let (ws, mut rx) = Workspace::testing_stub();
442 let (id, mut held, _) = ws.register_vault_ask(&caller(), a_draft(), false);
443
444 ws.abandon_vault_asks(&caller());
445
446 assert!(ws.parked_vault_asks.lock().is_empty(), "the teardown leaves nothing parked");
447 assert!(held.try_recv().is_err(), "the blocked caller is released as abandoned");
448 let mut endings = Vec::new();
449 while let Ok(frame) = rx.try_recv() {
450 if let SessionUpdate::VaultAskResolved { id: ended, ending, .. } =
451 std::sync::Arc::unwrap_or_clone(frame.update)
452 {
453 endings.push((ended, ending));
454 }
455 }
456 assert_eq!(endings, vec![(id, VaultAskEnding::Abandoned)]);
457 }
458}