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