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