Skip to main content

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