Skip to main content

endbasic_client/
cmds.rs

1// EndBASIC
2// Copyright 2021 Julio Merino
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17//! Commands to interact with the cloud service.
18
19use 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
35/// Category description for all symbols provided by this module.
36const 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
50/// The `LOGIN` command.
51pub 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    /// Creates a new `LOGIN` command.
60    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    /// Performs the login workflow against the server.
115    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
163/// The `LOGOUT` command.
164pub 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    /// Creates a new `LOGOUT` command.
173    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            // TODO(jmmv): Now that the access tokens are part of the service, we can easily allow
207            // logging in more than once within a session.  Consider adding a LOGOUT command first
208            // to make it easier to handle the CLOUD: drive on a second login.
209            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
244/// The `SHARE` command.
245///
246/// Note that this command is not exclusively for use by the cloud drive as this interacts with the
247/// generic storage layer.  As a result, one might say that this command belongs where other disk
248/// commands such as `DIR` are defined, but given that ACLs are primarily a cloud concept in our
249/// case, it makes sense to keep it here.
250pub 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    /// Creates a new `SHARE` command.
260    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    /// Parses a textual ACL specification and adds it to `add` or `remove.
309    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; // For clarity after splitting off the ACL change request.
312        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    /// Checks if a file is publicly readable by inspecting a set of ACLs.
328    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    /// Fetches and prints the ACLs for `filename`.
338    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
416/// Checks if a password is sufficiently complex and returns an error when it isn't.
417fn 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
439/// The `SIGNUP` command.
440pub struct SignupCommand {
441    metadata: Rc<CallableMetadata>,
442    service: Rc<RefCell<dyn Service>>,
443    console: Rc<RefCell<dyn Console>>,
444}
445
446impl SignupCommand {
447    /// Creates a new `SIGNUP` command.
448    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    /// Tries to read a boolean value until it is valid.  Returns `default` if the user hits enter.
466    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    /// Tries to get a password from the user until it is valid.
482    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            // TODO(jmmv): This should return an error of some form once we have error handling in
567            // the language.
568            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
590/// Adds all remote manipulation commands for `service` to the `machine`, using `console` to
591/// display information and `storage` to manipulate the remote drives.
592pub 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    // TODO(jmmv): Add forgotten tests for SHARE modifying ACLs.
935
936    #[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") // Default promotional email answer.
1002            .add_input_chars("y\n"); // Confirmation.
1003        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") // Promotional email answer.
1032            .add_input_chars("y\n"); // Confirmation.
1033        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") // Password complexity failure.
1059            .add_input_chars("123456\n") // Password complexity failure.
1060            .add_input_chars("AnotherPassword7\n")
1061            .add_input_chars("does not match\n") // Second password doesn't match.
1062            .add_input_chars("too simple\n") // Password complexity failure.
1063            .add_input_chars("123456\n") // Password complexity failure.
1064            .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") // Promotional email answer failure.
1068            .add_input_chars("n\n") // Promotional email answer.
1069            .add_input_chars("foo\n") // Confirmation failure.
1070            .add_input_chars("y\n"); // Confirmation.
1071        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") // Default promotional email answer.
1094            .add_input_chars("\n"); // Default confirmation.
1095        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") // Default promotional email answer.
1129            .add_input_chars("true\n"); // Confirmation.
1130        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}