endbasic_client/
cmds.rs

1// EndBASIC
2// Copyright 2021 Julio Merino
3//
4// Licensed under the Apache License, Version 2.0 (the "License"); you may not
5// use this file except in compliance with the License.  You may obtain a copy
6// of the License at:
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
13// License for the specific language governing permissions and limitations
14// under the License.
15
16//! Commands to interact with the cloud service.
17
18use 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
37/// Category description for all symbols provided by this module.
38const 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
52/// The `LOGIN` command.
53pub 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    /// Creates a new `LOGIN` command.
62    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    /// Performs the login workflow against the server.
116    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
166/// The `LOGOUT` command.
167pub 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    /// Creates a new `LOGOUT` command.
176    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            // TODO(jmmv): Now that the access tokens are part of the service, we can easily allow
209            // logging in more than once within a session.  Consider adding a LOGOUT command first
210            // to make it easier to handle the CLOUD: drive on a second login.
211            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
245/// The `SHARE` command.
246///
247/// Note that this command is not exclusively for use by the cloud drive as this interacts with the
248/// generic storage layer.  As a result, one might say that this command belongs where other disk
249/// commands such as `DIR` are defined, but given that ACLs are primarily a cloud concept in our
250/// case, it makes sense to keep it here.
251pub 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    /// Creates a new `SHARE` command.
261    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    /// Parses a textual ACL specification and adds it to `add` or `remove.
309    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; // For clarity after splitting off the ACL change request.
317        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    /// Checks if a file is publicly readable by inspecting a set of ACLs.
336    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    /// Fetches and prints the ACLs for `filename`.
346    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
420/// Checks if a password is sufficiently complex and returns an error when it isn't.
421fn 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
443/// The `SIGNUP` command.
444pub struct SignupCommand {
445    metadata: CallableMetadata,
446    service: Rc<RefCell<dyn Service>>,
447    console: Rc<RefCell<dyn Console>>,
448}
449
450impl SignupCommand {
451    /// Creates a new `SIGNUP` command.
452    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    /// Tries to read a boolean value until it is valid.  Returns `default` if the user hits enter.
469    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    /// Tries to get a password from the user until it is valid.
485    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            // TODO(jmmv): This should return an error of some form once we have error handling in
565            // the language.
566            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
588/// Adds all remote manipulation commands for `service` to the `machine`, using `console` to
589/// display information and `storage` to manipulate the remote drives.
590pub 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    // TODO(jmmv): Add forgotten tests for SHARE modifying ACLs.
947
948    #[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") // Default promotional email answer.
1017            .add_input_chars("y\n"); // Confirmation.
1018        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") // Promotional email answer.
1047            .add_input_chars("y\n"); // Confirmation.
1048        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") // Password complexity failure.
1074            .add_input_chars("123456\n") // Password complexity failure.
1075            .add_input_chars("AnotherPassword7\n")
1076            .add_input_chars("does not match\n") // Second password doesn't match.
1077            .add_input_chars("too simple\n") // Password complexity failure.
1078            .add_input_chars("123456\n") // Password complexity failure.
1079            .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") // Promotional email answer failure.
1083            .add_input_chars("n\n") // Promotional email answer.
1084            .add_input_chars("foo\n") // Confirmation failure.
1085            .add_input_chars("y\n"); // Confirmation.
1086        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") // Default promotional email answer.
1109            .add_input_chars("\n"); // Default confirmation.
1110        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") // Default promotional email answer.
1147            .add_input_chars("true\n"); // Confirmation.
1148        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}