1use crate::*;
20use async_trait::async_trait;
21use endbasic_core::{
22 ArgSep, ArgSepSyntax, CallError, CallResult, Callable, CallableMetadata,
23 CallableMetadataBuilder, ExprType, RepeatedSyntax, RepeatedTypeSyntax, RequiredValueSyntax,
24 Scope, SingularArgSyntax,
25};
26use endbasic_std::MachineBuilder;
27use endbasic_std::console::{Console, is_narrow, read_line, read_line_secure, refill_and_print};
28use endbasic_std::storage::{FileAcls, Storage};
29use endbasic_std::strings::parse_boolean;
30use std::borrow::Cow;
31use std::cell::RefCell;
32use std::rc::Rc;
33use std::str;
34
35const CATEGORY: &str = "Cloud access
37The EndBASIC service is a cloud service that provides online file sharing across users of \
38EndBASIC and the public.
39Files that have been shared publicly can be accessed without an account via the cloud:// file \
40system scheme. All you have to do is mount a user's cloud drive and then access the files as you \
41would with your own. For example:
42 MOUNT \"X\", \"cloud://user-123\": DIR \"X:\"
43To upload files and share them, you need to create an account. During account creation time, you \
44are assigned a unique, persistent drive in which you can store files privately. You can later \
45choose to share individual files with the public or with specific individuals, at which point \
46those people will be able to see them by mounting your drive.
47If you have any questions or experience any problems while interacting with the cloud service, \
48please contact support@endbasic.dev.";
49
50pub struct LoginCommand {
52 metadata: Rc<CallableMetadata>,
53 service: Rc<RefCell<dyn Service>>,
54 console: Rc<RefCell<dyn Console>>,
55 storage: Rc<RefCell<Storage>>,
56}
57
58impl LoginCommand {
59 pub fn new(
61 service: Rc<RefCell<dyn Service>>,
62 console: Rc<RefCell<dyn Console>>,
63 storage: Rc<RefCell<Storage>>,
64 ) -> Rc<Self> {
65 Rc::from(Self {
66 metadata: CallableMetadataBuilder::new("LOGIN")
67 .with_async(true)
68 .with_syntax(&[
69 (
70 &[SingularArgSyntax::RequiredValue(
71 RequiredValueSyntax {
72 name: Cow::Borrowed("username"),
73 vtype: ExprType::Text,
74 },
75 ArgSepSyntax::End,
76 )],
77 None,
78 ),
79 (
80 &[
81 SingularArgSyntax::RequiredValue(
82 RequiredValueSyntax {
83 name: Cow::Borrowed("username"),
84 vtype: ExprType::Text,
85 },
86 ArgSepSyntax::Exactly(ArgSep::Long),
87 ),
88 SingularArgSyntax::RequiredValue(
89 RequiredValueSyntax {
90 name: Cow::Borrowed("password"),
91 vtype: ExprType::Text,
92 },
93 ArgSepSyntax::End,
94 ),
95 ],
96 None,
97 ),
98 ])
99 .with_category(CATEGORY)
100 .with_description(
101 "Logs into the user's account.
102On a successful login, this mounts your personal drive under the CLOUD:/ location, which you can \
103access with any other file-related commands. Using the cloud:// file system scheme, you can mount \
104other people's drives with the MOUNT command.
105To create an account, use the SIGNUP command.",
106 )
107 .build(),
108 service,
109 console,
110 storage,
111 })
112 }
113
114 async fn do_login(&self, username: &str, password: &str) -> io::Result<()> {
116 let response = self.service.borrow_mut().login(username, password).await?;
117
118 {
119 let console = &mut *self.console.borrow_mut();
120 if !is_narrow(&*console) && !response.motd.is_empty() {
121 console.print("")?;
122 console.print("----- BEGIN SERVER MOTD -----")?;
123 for line in response.motd {
124 refill_and_print(console, [line], "")?;
125 }
126 console.print("----- END SERVER MOTD -----")?;
127 console.print("")?;
128 }
129 }
130
131 let mut storage = self.storage.borrow_mut();
132 storage.mount("CLOUD", &format!("cloud://{}", username))?;
133
134 Ok(())
135 }
136}
137
138#[async_trait(?Send)]
139impl Callable for LoginCommand {
140 fn metadata(&self) -> Rc<CallableMetadata> {
141 self.metadata.clone()
142 }
143
144 async fn async_exec(&self, scope: Scope<'_>) -> CallResult<()> {
145 if self.service.borrow().is_logged_in() {
146 return Err(CallError::Precondition("Cannot LOGIN again before LOGOUT".to_owned()));
147 }
148
149 let username = scope.get_string(0).to_owned();
150 let password = if scope.nargs() == 1 {
151 read_line_secure(&mut *self.console.borrow_mut(), "Password: ")
152 .await
153 .map_err(CallError::from)?
154 } else {
155 debug_assert_eq!(2, scope.nargs());
156 scope.get_string(1).to_owned()
157 };
158
159 self.do_login(&username, &password).await.map_err(CallError::from)
160 }
161}
162
163pub struct LogoutCommand {
165 metadata: Rc<CallableMetadata>,
166 service: Rc<RefCell<dyn Service>>,
167 console: Rc<RefCell<dyn Console>>,
168 storage: Rc<RefCell<Storage>>,
169}
170
171impl LogoutCommand {
172 pub fn new(
174 service: Rc<RefCell<dyn Service>>,
175 console: Rc<RefCell<dyn Console>>,
176 storage: Rc<RefCell<Storage>>,
177 ) -> Rc<Self> {
178 Rc::from(Self {
179 metadata: CallableMetadataBuilder::new("LOGOUT")
180 .with_async(true)
181 .with_syntax(&[(&[], None)])
182 .with_category(CATEGORY)
183 .with_description(
184 "Logs the user out of their account.
185Unmounts the CLOUD drive that was mounted by the LOGIN command. As a consequence of this, running \
186LOGOUT from within the CLOUD drive will fail.",
187 )
188 .build(),
189 service,
190 console,
191 storage,
192 })
193 }
194}
195
196#[async_trait(?Send)]
197impl Callable for LogoutCommand {
198 fn metadata(&self) -> Rc<CallableMetadata> {
199 self.metadata.clone()
200 }
201
202 async fn async_exec(&self, scope: Scope<'_>) -> CallResult<()> {
203 debug_assert_eq!(0, scope.nargs());
204
205 if !self.service.borrow().is_logged_in() {
206 return Err(CallError::Precondition("Must LOGIN first".to_owned()));
210 }
211
212 let unmounted = match self.storage.borrow_mut().unmount("CLOUD") {
213 Ok(()) => true,
214 Err(e) if e.kind() == io::ErrorKind::NotFound => false,
215 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
216 return Err(CallError::Precondition(
217 "Cannot log out while the CLOUD drive is active".to_owned(),
218 ));
219 }
220 Err(e) => {
221 return Err(CallError::from(io::Error::new(
222 e.kind(),
223 format!("Cannot log out: {}", e),
224 )));
225 }
226 };
227
228 self.service.borrow_mut().logout().await.map_err(CallError::from)?;
229
230 {
231 let mut console = self.console.borrow_mut();
232 console.print("").map_err(CallError::from)?;
233 if unmounted {
234 console.print(" Unmounted CLOUD drive").map_err(CallError::from)?;
235 }
236 console.print(" Good bye!").map_err(CallError::from)?;
237 console.print("").map_err(CallError::from)?;
238 }
239
240 Ok(())
241 }
242}
243
244pub struct ShareCommand {
251 metadata: Rc<CallableMetadata>,
252 service: Rc<RefCell<dyn Service>>,
253 console: Rc<RefCell<dyn Console>>,
254 storage: Rc<RefCell<Storage>>,
255 exec_base_url: String,
256}
257
258impl ShareCommand {
259 pub fn new<S: Into<String>>(
261 service: Rc<RefCell<dyn Service>>,
262 console: Rc<RefCell<dyn Console>>,
263 storage: Rc<RefCell<Storage>>,
264 exec_base_url: S,
265 ) -> Rc<Self> {
266 Rc::from(Self {
267 metadata: CallableMetadataBuilder::new("SHARE")
268 .with_async(true)
269 .with_syntax(&[(
270 &[SingularArgSyntax::RequiredValue(
271 RequiredValueSyntax {
272 name: Cow::Borrowed("filename"),
273 vtype: ExprType::Text,
274 },
275 ArgSepSyntax::Exactly(ArgSep::Long),
276 )],
277 Some(&RepeatedSyntax {
278 name: Cow::Borrowed("acl"),
279 type_syn: RepeatedTypeSyntax::TypedValue(ExprType::Text),
280 sep: ArgSepSyntax::Exactly(ArgSep::Long),
281 require_one: false,
282 allow_missing: false,
283 }),
284 )])
285 .with_category(CATEGORY)
286 .with_description(
287 "Displays or modifies the ACLs of a file.
288If given only a filename$, this command prints out the ACLs of the file.
289Otherwise, when given a list of ACL changes, applies those changes to the file. The acl1$ to \
290aclN$ arguments are strings of the form \"username+r\" or \"username-r\", where the former adds \
291\"username\" to the users allowed to read the file, and the latter removes \"username\" from the \
292list of users allowed to read the file.
293You can use the special \"public+r\" ACL to share a file with everyone. These files can be \
294auto-run via the web interface using the special URL that the command prints on success.
295Note that this command only works for cloud-based drives as it is designed to share files \
296among users of the EndBASIC service.",
297 )
298 .build(),
299 service,
300 console,
301 storage,
302 exec_base_url: exec_base_url.into(),
303 })
304 }
305}
306
307impl ShareCommand {
308 fn parse_acl(mut acl: String, add: &mut FileAcls, remove: &mut FileAcls) -> Result<(), String> {
310 let change = if acl.len() < 3 { String::new() } else { acl.split_off(acl.len() - 2) };
311 let username = acl; match (username, change.as_str()) {
313 (username, "+r") if !username.is_empty() => add.add_reader(username),
314 (username, "+R") if !username.is_empty() => add.add_reader(username),
315 (username, "-r") if !username.is_empty() => remove.add_reader(username),
316 (username, "-R") if !username.is_empty() => remove.add_reader(username),
317 (username, change) => {
318 return Err(format!(
319 "Invalid ACL '{}{}': must be of the form \"username+r\" or \"username-r\"",
320 username, change
321 ));
322 }
323 }
324 Ok(())
325 }
326
327 fn has_public_acl(acls: &FileAcls) -> bool {
329 for reader in acls.readers() {
330 if reader.to_lowercase() == "public" {
331 return true;
332 }
333 }
334 false
335 }
336
337 async fn show_acls(&self, filename: &str) -> io::Result<()> {
339 let acls = self.storage.borrow().get_acls(filename).await?;
340
341 let mut console = self.console.borrow_mut();
342 console.print("")?;
343 if acls.readers().is_empty() {
344 console.print(&format!(" No ACLs on {}", filename))?;
345 } else {
346 console.print(&format!(" Reader ACLs on {}:", filename))?;
347 for acl in acls.readers() {
348 console.print(&format!(" {}", acl))?;
349 }
350 }
351 console.print("")
352 }
353}
354
355#[async_trait(?Send)]
356impl Callable for ShareCommand {
357 fn metadata(&self) -> Rc<CallableMetadata> {
358 self.metadata.clone()
359 }
360
361 async fn async_exec(&self, scope: Scope<'_>) -> CallResult<()> {
362 debug_assert_ne!(0, scope.nargs());
363 let filename = scope.get_string(0).to_owned();
364
365 let mut add = FileAcls::default();
366 let mut remove = FileAcls::default();
367 for reg in 1..scope.nargs() as u8 {
368 let t = scope.get_string(reg).to_owned();
369 ShareCommand::parse_acl(t, &mut add, &mut remove)
370 .map_err(|e| CallError::Syntax(scope.get_pos(reg), e))?;
371 }
372
373 if add.is_empty() && remove.is_empty() {
374 return self.show_acls(&filename).await.map_err(CallError::from);
375 }
376
377 self.storage
378 .borrow_mut()
379 .update_acls(&filename, &add, &remove)
380 .await
381 .map_err(CallError::from)?;
382
383 if Self::has_public_acl(&add) {
384 let filename = match filename.split_once('/') {
385 Some((_drive, path)) => path,
386 None => &filename,
387 };
388
389 let mut console = self.console.borrow_mut();
390 console.print("").map_err(CallError::from)?;
391 refill_and_print(
392 &mut *console,
393 [
394 "You have made the file publicly readable. As a result, other people can now \
395auto-run your public file by visiting:",
396 &format!(
397 "{}?run={}/{}",
398 self.exec_base_url,
399 self.service
400 .borrow()
401 .logged_in_username()
402 .expect("SHARE can only succeed against logged in cloud drives"),
403 filename
404 ),
405 ],
406 " ",
407 )
408 .map_err(CallError::from)?;
409 console.print("").map_err(CallError::from)?;
410 }
411
412 Ok(())
413 }
414}
415
416fn validate_password_complexity(password: &str) -> Result<(), &'static str> {
418 if password.len() < 8 {
419 return Err("Must be at least 8 characters long");
420 }
421
422 let mut alphabetic = false;
423 let mut numeric = false;
424 for ch in password.chars() {
425 if ch.is_alphabetic() {
426 alphabetic = true;
427 } else if ch.is_numeric() {
428 numeric = true;
429 }
430 }
431
432 if !alphabetic || !numeric {
433 return Err("Must contain letters and numbers");
434 }
435
436 Ok(())
437}
438
439pub struct SignupCommand {
441 metadata: Rc<CallableMetadata>,
442 service: Rc<RefCell<dyn Service>>,
443 console: Rc<RefCell<dyn Console>>,
444}
445
446impl SignupCommand {
447 pub fn new(service: Rc<RefCell<dyn Service>>, console: Rc<RefCell<dyn Console>>) -> Rc<Self> {
449 Rc::from(Self {
450 metadata: CallableMetadataBuilder::new("SIGNUP")
451 .with_async(true)
452 .with_syntax(&[(&[], None)])
453 .with_category(CATEGORY)
454 .with_description(
455 "Creates a new user account interactively.
456This command will ask you for your personal information to create an account in the EndBASIC \
457cloud service. You will be asked for confirmation before proceeding.",
458 )
459 .build(),
460 service,
461 console,
462 })
463 }
464
465 async fn read_bool(console: &mut dyn Console, prompt: &str, default: bool) -> io::Result<bool> {
467 loop {
468 match read_line(console, prompt, "", None).await? {
469 s if s.is_empty() => return Ok(default),
470 s => match parse_boolean(s.trim_end()) {
471 Ok(b) => return Ok(b),
472 Err(_) => {
473 console.print("Invalid input; try again.")?;
474 continue;
475 }
476 },
477 }
478 }
479 }
480
481 async fn read_password(console: &mut dyn Console) -> io::Result<String> {
483 loop {
484 let password = read_line_secure(console, "Password: ").await?;
485 match validate_password_complexity(&password) {
486 Ok(()) => (),
487 Err(e) => {
488 console.print(&format!("Invalid password: {}; try again.", e))?;
489 continue;
490 }
491 }
492
493 let second_password = read_line_secure(console, "Retype password: ").await?;
494 if second_password != password {
495 console.print("Passwords do not match; try again.")?;
496 continue;
497 }
498
499 return Ok(password);
500 }
501 }
502}
503
504#[async_trait(?Send)]
505impl Callable for SignupCommand {
506 fn metadata(&self) -> Rc<CallableMetadata> {
507 self.metadata.clone()
508 }
509
510 async fn async_exec(&self, scope: Scope<'_>) -> CallResult<()> {
511 debug_assert_eq!(0, scope.nargs());
512
513 let console = &mut *self.console.borrow_mut();
514 console.print("").map_err(CallError::from)?;
515 refill_and_print(
516 console,
517 ["Let's gather some information to create your cloud account.",
518"You can abort this process at any time by hitting Ctrl+C and you will be given a chance to \
519review your inputs before creating the account."],
520 " ",
521 ).map_err(CallError::from)?;
522 console.print("").map_err(CallError::from)?;
523
524 let username = read_line(console, "Username: ", "", None).await.map_err(CallError::from)?;
525 let password = Self::read_password(console).await.map_err(CallError::from)?;
526
527 console.print("").map_err(CallError::from)?;
528 refill_and_print(
529 console,
530 [
531 "We also need your email address to activate your account.",
532 "Your email address will be kept on file in case we have to notify you of \
533important service issues and will never be made public. You will be asked if you want to receive \
534promotional email messages (like new release announcements) or not, and your selection here will \
535have no adverse impact in the service you receive.",
536 ],
537 " ",
538 )
539 .map_err(CallError::from)?;
540 console.print("").map_err(CallError::from)?;
541
542 let email =
543 read_line(console, "Email address: ", "", None).await.map_err(CallError::from)?;
544 let promotional_email =
545 Self::read_bool(console, "Receive promotional email (y/N)? ", false)
546 .await
547 .map_err(CallError::from)?;
548
549 console.print("").map_err(CallError::from)?;
550 refill_and_print(
551 console,
552 ["We are ready to go. Please review your answers before proceeding."],
553 " ",
554 )
555 .map_err(CallError::from)?;
556 console.print("").map_err(CallError::from)?;
557
558 console.print(&format!("Username: {}", username)).map_err(CallError::from)?;
559 console.print(&format!("Email address: {}", email)).map_err(CallError::from)?;
560 console
561 .print(&format!("Promotional email: {}", if promotional_email { "yes" } else { "no" }))
562 .map_err(CallError::from)?;
563 let proceed =
564 Self::read_bool(console, "Continue (y/N)? ", false).await.map_err(CallError::from)?;
565 if !proceed {
566 return Ok(());
569 }
570
571 let request = SignupRequest { username, password, email, promotional_email };
572 self.service.borrow_mut().signup(&request).await.map_err(CallError::from)?;
573
574 console.print("").map_err(CallError::from)?;
575 refill_and_print(
576 console,
577 ["Your account has been created and is pending activation.",
578"Check your email now and look for a message from the EndBASIC Service. Follow the instructions \
579in it to activate your account. Make sure to check your spam folder.",
580"Once your account is activated, come back here and use LOGIN to get started!",
581"If you encounter any problems, please contact support@endbasic.dev."],
582 " ",
583 ).map_err(CallError::from)?;
584 console.print("").map_err(CallError::from)?;
585
586 Ok(())
587 }
588}
589
590pub fn add_all<S: Into<String>>(
593 machine: &mut MachineBuilder,
594 service: Rc<RefCell<dyn Service>>,
595 console: Rc<RefCell<dyn Console>>,
596 storage: Rc<RefCell<Storage>>,
597 exec_base_url: S,
598) {
599 storage
600 .borrow_mut()
601 .register_scheme("cloud", Box::from(CloudDriveFactory::new(service.clone())));
602
603 machine.add_callable(LoginCommand::new(service.clone(), console.clone(), storage.clone()));
604 machine.add_callable(LogoutCommand::new(service.clone(), console.clone(), storage.clone()));
605 machine.add_callable(ShareCommand::new(
606 service.clone(),
607 console.clone(),
608 storage,
609 exec_base_url,
610 ));
611 machine.add_callable(SignupCommand::new(service, console));
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::testutils::*;
618 use endbasic_std::{console::CharsXY, testutils::*};
619
620 #[test]
621 fn test_cloud_scheme_always_available() {
622 let t = ClientTester::default();
623 assert!(t.get_storage().borrow().has_scheme("cloud"));
624 }
625
626 #[test]
627 fn test_login_ok_with_password() {
628 let mut t = ClientTester::default();
629 t.get_service().borrow_mut().add_mock_login(
630 "the-username",
631 "the-password",
632 Ok(LoginResponse { access_token: AccessToken::new("random token"), motd: vec![] }),
633 );
634 assert!(!t.get_storage().borrow().mounted().contains_key("CLOUD"));
635 t.run(format!(r#"LOGIN "{}", "{}""#, "the-username", "the-password"))
636 .expect_access_token("random token")
637 .check();
638 assert!(t.get_storage().borrow().mounted().contains_key("CLOUD"));
639 }
640
641 #[test]
642 fn test_login_ok_ask_password() {
643 let t = ClientTester::default();
644 t.get_service().borrow_mut().add_mock_login(
645 "the-username",
646 "the-password",
647 Ok(LoginResponse { access_token: AccessToken::new("random token"), motd: vec![] }),
648 );
649 let storage = t.get_storage();
650 assert!(!storage.borrow().mounted().contains_key("CLOUD"));
651
652 t.get_console().borrow_mut().set_interactive(true);
653 let mut exp_output =
654 vec![CapturedOut::Write("Password: ".to_string()), CapturedOut::SyncNow];
655 for _ in 0.."the-password".len() {
656 exp_output.push(CapturedOut::Write("*".to_string()));
657 }
658 exp_output.push(CapturedOut::Print("".to_owned()));
659
660 t.add_input_chars("the-password")
661 .add_input_chars("\n")
662 .run(format!(r#"LOGIN "{}""#, "the-username"))
663 .expect_access_token("random token")
664 .expect_output(exp_output)
665 .check();
666
667 assert!(storage.borrow().mounted().contains_key("CLOUD"));
668 }
669
670 #[test]
671 fn test_login_skip_motd_on_narrow_console() {
672 let mut t = ClientTester::default();
673 t.get_console().borrow_mut().set_size_chars(CharsXY::new(10, 0));
674 t.get_service().borrow_mut().add_mock_login(
675 "the-username",
676 "the-password",
677 Ok(LoginResponse {
678 access_token: AccessToken::new("random token"),
679 motd: vec!["first line".to_owned(), "second line".to_owned()],
680 }),
681 );
682 t.run(format!(r#"LOGIN "{}", "{}""#, "the-username", "the-password"))
683 .expect_access_token("random token")
684 .check();
685 }
686
687 #[test]
688 fn test_login_show_motd_on_wide_console() {
689 let mut t = ClientTester::default();
690 t.get_service().borrow_mut().add_mock_login(
691 "the-username",
692 "the-password",
693 Ok(LoginResponse {
694 access_token: AccessToken::new("random token"),
695 motd: vec!["first line".to_owned(), "second line".to_owned()],
696 }),
697 );
698 t.run(format!(r#"LOGIN "{}", "{}""#, "the-username", "the-password"))
699 .expect_prints([
700 "",
701 "----- BEGIN SERVER MOTD -----",
702 "first line",
703 "second line",
704 "----- END SERVER MOTD -----",
705 "",
706 ])
707 .expect_access_token("random token")
708 .check();
709 }
710
711 #[test]
712 fn test_login_bad_credentials() {
713 let mut t = ClientTester::default();
714 t.get_service().borrow_mut().add_mock_login(
715 "bad-user",
716 "the-password",
717 Err(io::Error::new(io::ErrorKind::PermissionDenied, "Unknown user")),
718 );
719 t.run(format!(r#"LOGIN "{}", "{}""#, "bad-user", "the-password"))
720 .expect_err("1:1: Unknown user")
721 .check();
722 t.get_service().borrow_mut().add_mock_login(
723 "the-username",
724 "bad-password",
725 Err(io::Error::new(io::ErrorKind::PermissionDenied, "Invalid password")),
726 );
727 t.run(format!(r#"LOGIN "{}", "{}""#, "the-username", "bad-password"))
728 .expect_err("1:1: Invalid password")
729 .check();
730 assert!(!t.get_storage().borrow().mounted().contains_key("CLOUD"));
731 }
732
733 #[test]
734 fn test_login_twice() {
735 let mut t = ClientTester::default();
736 t.get_service().borrow_mut().add_mock_login(
737 "the-username",
738 "the-password",
739 Ok(LoginResponse { access_token: AccessToken::new("random token"), motd: vec![] }),
740 );
741 assert!(!t.get_storage().borrow().mounted().contains_key("CLOUD"));
742 t.run(r#"LOGIN "the-username", "the-password": LOGIN "a", "b""#)
743 .expect_access_token("random token")
744 .expect_err("1:39: Cannot LOGIN again before LOGOUT")
745 .check();
746 assert!(t.get_storage().borrow().mounted().contains_key("CLOUD"));
747 }
748
749 #[test]
750 fn test_login_errors() {
751 client_check_stmt_compilation_err(
752 "1:1: LOGIN expected <username$> | <username$, password$>",
753 r#"LOGIN"#,
754 );
755 client_check_stmt_compilation_err(
756 "1:1: LOGIN expected <username$> | <username$, password$>",
757 r#"LOGIN "a", "b", "c""#,
758 );
759 client_check_stmt_compilation_err(
760 "1:1: LOGIN expected <username$> | <username$, password$>",
761 r#"LOGIN , "c""#,
762 );
763 client_check_stmt_compilation_err(
764 "1:1: LOGIN expected <username$> | <username$, password$>",
765 r#"LOGIN ;"#,
766 );
767 client_check_stmt_compilation_err("1:7: Expected STRING but found INTEGER", r#"LOGIN 3"#);
768 client_check_stmt_compilation_err(
769 "1:7: Expected STRING but found INTEGER",
770 r#"LOGIN 3, "a""#,
771 );
772 client_check_stmt_compilation_err(
773 "1:12: Expected STRING but found INTEGER",
774 r#"LOGIN "a", 3"#,
775 );
776 }
777
778 #[tokio::test]
779 async fn test_logout_ok_cloud_not_mounted() {
780 let mut t = ClientTester::default();
781 t.get_service().borrow_mut().do_login().await;
782 t.run(r#"LOGOUT"#).expect_prints(["", " Good bye!", ""]).check();
783 assert!(!t.get_storage().borrow().mounted().contains_key("CLOUD"));
784 }
785
786 #[tokio::test]
787 async fn test_logout_ok_unmount_cloud() {
788 let mut t = ClientTester::default();
789 t.get_service().borrow_mut().do_login().await;
790 t.get_storage().borrow_mut().mount("CLOUD", "memory://").unwrap();
791 t.run(r#"LOGOUT"#)
792 .expect_prints(["", " Unmounted CLOUD drive", " Good bye!", ""])
793 .check();
794 assert!(!t.get_storage().borrow().mounted().contains_key("CLOUD"));
795 }
796
797 #[tokio::test]
798 async fn test_logout_cloud_mounted_and_active() {
799 let mut t = ClientTester::default();
800 t.get_service().borrow_mut().do_login().await;
801 t.get_storage().borrow_mut().mount("CLOUD", "memory://").unwrap();
802 t.get_storage().borrow_mut().cd("CLOUD:/").unwrap();
803 t.run(r#"LOGOUT"#)
804 .expect_err("1:1: Cannot log out while the CLOUD drive is active")
805 .expect_access_token("$")
806 .check();
807 assert!(t.get_storage().borrow().mounted().contains_key("CLOUD"));
808 }
809
810 #[test]
811 fn test_logout_errors() {
812 client_check_stmt_compilation_err("1:1: LOGOUT expected no arguments", r#"LOGOUT "a""#);
813 client_check_stmt_err("1:1: Must LOGIN first", r#"LOGOUT"#);
814 }
815
816 #[test]
817 fn test_login_logout_flow_once() {
818 let mut t = ClientTester::default();
819 t.get_service().borrow_mut().add_mock_login(
820 "u1",
821 "p1",
822 Ok(LoginResponse { access_token: AccessToken::new("token 1"), motd: vec![] }),
823 );
824 assert!(!t.get_storage().borrow().mounted().contains_key("CLOUD"));
825 t.run(r#"LOGIN "u1", "p1": LOGOUT"#)
826 .expect_prints(["", " Unmounted CLOUD drive", " Good bye!", ""])
827 .check();
828 assert!(!t.get_storage().borrow().mounted().contains_key("CLOUD"));
829 }
830
831 #[test]
832 fn test_login_logout_flow_multiple() {
833 let mut t = ClientTester::default();
834 t.get_service().borrow_mut().add_mock_login(
835 "u1",
836 "p1",
837 Ok(LoginResponse { access_token: AccessToken::new("token 1"), motd: vec![] }),
838 );
839 t.get_service().borrow_mut().add_mock_login(
840 "u2",
841 "p2",
842 Ok(LoginResponse { access_token: AccessToken::new("token 2"), motd: vec![] }),
843 );
844 assert!(!t.get_storage().borrow().mounted().contains_key("CLOUD"));
845 t.run(r#"LOGIN "u1", "p1": LOGOUT: LOGIN "u2", "p2""#)
846 .expect_prints(["", " Unmounted CLOUD drive", " Good bye!", ""])
847 .expect_access_token("token 2")
848 .check();
849 assert!(t.get_storage().borrow().mounted().contains_key("CLOUD"));
850 }
851
852 #[test]
853 fn test_share_parse_acl_ok() {
854 let mut add = FileAcls::default();
855 let mut remove = FileAcls::default();
856
857 ShareCommand::parse_acl("user1+r".to_owned(), &mut add, &mut remove).unwrap();
858 ShareCommand::parse_acl("user2+R".to_owned(), &mut add, &mut remove).unwrap();
859 ShareCommand::parse_acl("X-r".to_owned(), &mut add, &mut remove).unwrap();
860 ShareCommand::parse_acl("Y-R".to_owned(), &mut add, &mut remove).unwrap();
861 assert_eq!(&["user1".to_owned(), "user2".to_owned()], add.readers());
862 assert_eq!(&["X".to_owned(), "Y".to_owned()], remove.readers());
863 }
864
865 #[test]
866 fn test_share_has_public_acls() {
867 let mut acls = FileAcls::default();
868 assert!(!ShareCommand::has_public_acl(&acls));
869 acls.add_reader("foo");
870 assert!(!ShareCommand::has_public_acl(&acls));
871 acls.add_reader("PuBlIc");
872 assert!(ShareCommand::has_public_acl(&acls));
873 }
874
875 #[test]
876 fn test_share_parse_acl_errors() {
877 let mut add = FileAcls::default().with_readers(["before1".to_owned()]);
878 let mut remove = FileAcls::default().with_readers(["before2".to_owned()]);
879
880 for acl in &["", "r", "+r", "-r", "foo+", "bar-"] {
881 let err = ShareCommand::parse_acl(acl.to_string(), &mut add, &mut remove).unwrap_err();
882 let message = err.to_string();
883 assert!(message.contains("Invalid ACL"));
884 assert!(message.contains(acl));
885 }
886
887 assert_eq!(&["before1".to_owned()], add.readers());
888 assert_eq!(&["before2".to_owned()], remove.readers());
889 }
890
891 #[tokio::test]
892 async fn test_share_print_no_acls() {
893 let mut t = ClientTester::default();
894 t.get_storage().borrow_mut().put("MEMORY:/FOO", b"").await.unwrap();
895 t.run(r#"SHARE "MEMORY:/FOO""#)
896 .expect_prints(["", " No ACLs on MEMORY:/FOO", ""])
897 .expect_file("MEMORY:/FOO", "")
898 .check();
899 }
900
901 #[tokio::test]
902 async fn test_share_print_some_acls() {
903 let mut t = ClientTester::default();
904 {
905 let storage = t.get_storage();
906 let mut storage = storage.borrow_mut();
907 storage.put("MEMORY:/FOO", b"").await.unwrap();
908 storage
909 .update_acls(
910 "MEMORY:/FOO",
911 &FileAcls::default().with_readers(["some".to_owned(), "person".to_owned()]),
912 &FileAcls::default(),
913 )
914 .await
915 .unwrap();
916 }
917 t.run(r#"SHARE "MEMORY:/FOO""#)
918 .expect_prints(["", " Reader ACLs on MEMORY:/FOO:", " person", " some", ""])
919 .expect_file("MEMORY:/FOO", "")
920 .check();
921 }
922
923 #[tokio::test]
924 async fn test_share_make_public() {
925 let mut t = ClientTester::default();
926 t.get_storage().borrow_mut().put("MEMORY:/FOO.BAS", b"").await.unwrap();
927 t.get_service().borrow_mut().do_login().await;
928 let mut checker = t.run(r#"SHARE "MEMORY:/FOO.BAS", "Public+r""#);
929 let output = flatten_output(checker.take_captured_out());
930 checker.expect_file("MEMORY:/FOO.BAS", "").expect_access_token("$").check();
931 assert!(output.contains("https://repl.example.com/?run=logged-in-username/FOO.BAS"));
932 }
933
934 #[test]
937 fn test_share_errors() {
938 client_check_stmt_compilation_err(
939 "1:1: SHARE expected filename$[, acl1$, .., aclN$]",
940 r#"SHARE"#,
941 );
942 client_check_stmt_compilation_err("1:7: Expected STRING but found INTEGER", r#"SHARE 1"#);
943 client_check_stmt_compilation_err(
944 "1:1: SHARE expected filename$[, acl1$, .., aclN$]",
945 r#"SHARE , "a""#,
946 );
947 client_check_stmt_compilation_err(
948 "1:10: SHARE expected filename$[, acl1$, .., aclN$]",
949 r#"SHARE "a"; "b""#,
950 );
951 client_check_stmt_compilation_err(
952 "1:15: SHARE expected filename$[, acl1$, .., aclN$]",
953 r#"SHARE "a", "b"; "c""#,
954 );
955 client_check_stmt_compilation_err(
956 "1:12: SHARE expected filename$[, acl1$, .., aclN$]",
957 r#"SHARE "a", , "b""#,
958 );
959 client_check_stmt_compilation_err(
960 "1:12: Expected STRING but found INTEGER",
961 r#"SHARE "a", 3, "b""#,
962 );
963 client_check_stmt_err(
964 r#"1:12: Invalid ACL 'foobar': must be of the form "username+r" or "username-r""#,
965 r#"SHARE "a", "foobar""#,
966 );
967 }
968
969 #[test]
970 fn test_validate_password_complexity_ok() {
971 validate_password_complexity("theP4ssword").unwrap();
972 }
973
974 #[test]
975 fn test_validate_password_complexity_error() {
976 validate_password_complexity("a").unwrap_err().contains("8 characters");
977 validate_password_complexity("abcdefg").unwrap_err().contains("8 characters");
978 validate_password_complexity("long enough").unwrap_err().contains("letters and numbers");
979 validate_password_complexity("1234567890").unwrap_err().contains("letters and numbers");
980 }
981
982 #[test]
983 fn test_signup_ok() {
984 let t = ClientTester::default();
985 t.get_service().borrow_mut().add_mock_signup(
986 SignupRequest {
987 username: "the-username".to_owned(),
988 password: "theP4ssword".to_owned(),
989 email: "some@example.com".to_owned(),
990 promotional_email: false,
991 },
992 Ok(()),
993 );
994 t.get_console().borrow_mut().set_interactive(true);
995
996 let mut t = t
997 .add_input_chars("the-username\n")
998 .add_input_chars("theP4ssword\n")
999 .add_input_chars("theP4ssword\n")
1000 .add_input_chars("some@example.com\n")
1001 .add_input_chars("\n") .add_input_chars("y\n"); let mut c = t.run("SIGNUP".to_owned());
1004 let output = flatten_output(c.take_captured_out());
1005 c.check();
1006
1007 assert!(output.contains("Username: the-username"));
1008 assert!(output.contains("Email address: some@example.com"));
1009 assert!(output.contains("Promotional email: no"));
1010 }
1011
1012 #[test]
1013 fn test_signup_ok_with_promotional_email() {
1014 let t = ClientTester::default();
1015 t.get_service().borrow_mut().add_mock_signup(
1016 SignupRequest {
1017 username: "foobar".to_owned(),
1018 password: "AnotherPassword5".to_owned(),
1019 email: "other@example.com".to_owned(),
1020 promotional_email: true,
1021 },
1022 Ok(()),
1023 );
1024 t.get_console().borrow_mut().set_interactive(true);
1025
1026 let mut t = t
1027 .add_input_chars("foobar\n")
1028 .add_input_chars("AnotherPassword5\n")
1029 .add_input_chars("AnotherPassword5\n")
1030 .add_input_chars("other@example.com\n")
1031 .add_input_chars("yes\n") .add_input_chars("y\n"); let mut c = t.run("SIGNUP".to_owned());
1034 let output = flatten_output(c.take_captured_out());
1035 c.check();
1036
1037 assert!(output.contains("Username: foobar"));
1038 assert!(output.contains("Email address: other@example.com"));
1039 assert!(output.contains("Promotional email: yes"));
1040 }
1041
1042 #[test]
1043 fn test_signup_ok_retry_inputs() {
1044 let t = ClientTester::default();
1045 t.get_service().borrow_mut().add_mock_signup(
1046 SignupRequest {
1047 username: "the-username".to_owned(),
1048 password: "AnotherPassword7".to_owned(),
1049 email: "some@example.com".to_owned(),
1050 promotional_email: false,
1051 },
1052 Ok(()),
1053 );
1054 t.get_console().borrow_mut().set_interactive(true);
1055
1056 let mut t = t
1057 .add_input_chars("the-username\n")
1058 .add_input_chars("too simple\n") .add_input_chars("123456\n") .add_input_chars("AnotherPassword7\n")
1061 .add_input_chars("does not match\n") .add_input_chars("too simple\n") .add_input_chars("123456\n") .add_input_chars("AnotherPassword7\n")
1065 .add_input_chars("AnotherPassword7\n")
1066 .add_input_chars("some@example.com\n")
1067 .add_input_chars("123\n") .add_input_chars("n\n") .add_input_chars("foo\n") .add_input_chars("y\n"); let mut c = t.run("SIGNUP".to_owned());
1072 let output = flatten_output(c.take_captured_out());
1073 c.check();
1074
1075 assert!(output.contains("Invalid input"));
1076 assert!(output.contains("Invalid password: Must contain"));
1077 assert!(output.contains("Passwords do not match"));
1078 assert!(output.contains("Username: the-username"));
1079 assert!(output.contains("Email address: some@example.com"));
1080 assert!(output.contains("Promotional email: no"));
1081 }
1082
1083 #[test]
1084 fn test_signup_abort() {
1085 let t = ClientTester::default();
1086 t.get_console().borrow_mut().set_interactive(true);
1087
1088 let mut t = t
1089 .add_input_chars("the-username\n")
1090 .add_input_chars("theP4ssword\n")
1091 .add_input_chars("theP4ssword\n")
1092 .add_input_chars("some@example.com\n")
1093 .add_input_chars("\n") .add_input_chars("\n"); let mut c = t.run("SIGNUP".to_owned());
1096 let output = flatten_output(c.take_captured_out());
1097 c.check();
1098
1099 assert!(output.contains("Username: the-username"));
1100 assert!(output.contains("Email address: some@example.com"));
1101 assert!(output.contains("Promotional email: no"));
1102 }
1103
1104 #[test]
1105 fn test_singup_errors() {
1106 client_check_stmt_compilation_err("1:1: SIGNUP expected no arguments", r#"SIGNUP "a""#);
1107 }
1108
1109 #[test]
1110 fn test_signup_process_error() {
1111 let t = ClientTester::default();
1112 t.get_service().borrow_mut().add_mock_signup(
1113 SignupRequest {
1114 username: "the-username".to_owned(),
1115 password: "theP4ssword".to_owned(),
1116 email: "some@example.com".to_owned(),
1117 promotional_email: false,
1118 },
1119 Err(io::Error::new(io::ErrorKind::AlreadyExists, "Some error")),
1120 );
1121 t.get_console().borrow_mut().set_interactive(true);
1122
1123 let mut t = t
1124 .add_input_chars("the-username\n")
1125 .add_input_chars("theP4ssword\n")
1126 .add_input_chars("theP4ssword\n")
1127 .add_input_chars("some@example.com\n")
1128 .add_input_chars("\n") .add_input_chars("true\n"); let mut c = t.run("SIGNUP".to_owned());
1131 let output = flatten_output(c.take_captured_out());
1132 c.expect_err("1:1: Some error").check();
1133
1134 assert!(output.contains("Username: the-username"));
1135 assert!(output.contains("Email address: some@example.com"));
1136 assert!(output.contains("Promotional email: no"));
1137 }
1138}