endbasic_client/
cloud.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//! Cloud-based implementation of the EndBASIC service client.
17
18use crate::*;
19use async_trait::async_trait;
20use bytes::Buf;
21use endbasic_std::console::remove_control_chars;
22use reqwest::header::HeaderMap;
23use reqwest::Response;
24use reqwest::StatusCode;
25use std::cell::RefCell;
26use std::io;
27use std::rc::Rc;
28use std::str;
29use url::Url;
30
31/// Converts a `reqwest::Response` to an `io::Error`.  The response should have a non-OK status.
32async fn http_response_to_io_error(response: Response) -> io::Error {
33    let status = response.status();
34
35    let kind = match status {
36        StatusCode::OK => panic!("Should not have been called on a successful request"),
37
38        // Match against the codes we know the server explicitly hands us.
39        StatusCode::BAD_REQUEST => io::ErrorKind::InvalidInput,
40        StatusCode::FORBIDDEN => io::ErrorKind::PermissionDenied,
41        StatusCode::INSUFFICIENT_STORAGE => io::ErrorKind::Other,
42        StatusCode::INTERNAL_SERVER_ERROR => io::ErrorKind::Other,
43        StatusCode::NOT_FOUND => io::ErrorKind::NotFound,
44        StatusCode::PAYLOAD_TOO_LARGE => io::ErrorKind::InvalidInput,
45        StatusCode::SERVICE_UNAVAILABLE => io::ErrorKind::AddrNotAvailable,
46        StatusCode::UNAUTHORIZED => io::ErrorKind::PermissionDenied,
47
48        _ => io::ErrorKind::Other,
49    };
50
51    match response.text().await {
52        Ok(text) => match serde_json::from_str::<ErrorResponse>(&text) {
53            Ok(response) => io::Error::new(
54                kind,
55                format!("{} (server code: {})", remove_control_chars(response.message), status),
56            ),
57            _ => io::Error::new(
58                kind,
59                format!(
60                    "HTTP request returned status {} with text '{}'",
61                    status,
62                    remove_control_chars(text)
63                ),
64            ),
65        },
66        Err(e) => io::Error::new(
67            kind,
68            format!(
69                "HTTP request returned status {} and failed to get text due to {}",
70                status,
71                remove_control_chars(e.to_string())
72            ),
73        ),
74    }
75}
76
77/// Converts a `reqwest::Error` to an `io::Error`.
78fn reqwest_error_to_io_error(e: reqwest::Error) -> io::Error {
79    io::Error::new(io::ErrorKind::Other, format!("{}", e))
80}
81
82/// Container for authentication data to track after login.
83struct AuthData {
84    username: String,
85    access_token: AccessToken,
86}
87
88/// An implementation of the EndBASIC service client that talks to a remote server.
89#[cfg_attr(test, derive(Clone))]
90pub struct CloudService {
91    api_address: Url,
92    client: reqwest::Client,
93    auth_data: Rc<RefCell<Option<AuthData>>>,
94}
95
96impl CloudService {
97    /// Creates a new client for the cloud service that talks to `api_address`.
98    pub fn new(api_address: &str) -> io::Result<Self> {
99        let url = match Url::parse(api_address) {
100            Ok(url) => url,
101            Err(e) => {
102                return Err(io::Error::new(
103                    io::ErrorKind::InvalidInput,
104                    format!("Invalid base API address: {}", e),
105                ))
106            }
107        };
108
109        if !(url.path().is_empty() || url.path() == "/") {
110            return Err(io::Error::new(
111                io::ErrorKind::InvalidInput,
112                "Invalid base API address: cannot contain a path".to_owned(),
113            ));
114        }
115
116        let auth_data = Rc::from(RefCell::from(None));
117
118        Ok(Self { api_address: url, client: reqwest::Client::default(), auth_data })
119    }
120
121    /// Generates a service URL with the given `path`.
122    fn make_url(&self, path: &str) -> Url {
123        assert!(path.starts_with("api/"));
124        let mut url = self.api_address.clone();
125        assert!(url.path().is_empty() || url.path() == "/");
126        url.set_path(path);
127        url
128    }
129
130    /// Returns the default headers to add to every request.
131    fn default_headers(&self) -> HeaderMap {
132        let mut headers = HeaderMap::new();
133        headers.insert(
134            "x-endbasic-client-version",
135            env!("CARGO_PKG_VERSION")
136                .parse()
137                .expect("Package version should have been serializable"),
138        );
139        headers
140    }
141
142    /// Checks if the given auth data object is present and returns it, or else returns a permission
143    /// denied error.
144    fn require_auth_data(data: Option<&AuthData>) -> io::Result<&AuthData> {
145        match data.as_ref() {
146            Some(data) => Ok(data),
147            None => {
148                Err(io::Error::new(io::ErrorKind::PermissionDenied, "Not logged in yet".to_owned()))
149            }
150        }
151    }
152}
153
154#[async_trait(?Send)]
155impl Service for CloudService {
156    async fn signup(&mut self, request: &SignupRequest) -> io::Result<()> {
157        let response = self
158            .client
159            .post(self.make_url("api/signup"))
160            .headers(self.default_headers())
161            .header("Content-Type", "application/json")
162            .body(serde_json::to_vec(&request)?)
163            .send()
164            .await
165            .map_err(reqwest_error_to_io_error)?;
166        match response.status() {
167            StatusCode::OK => Ok(()),
168            _ => Err(http_response_to_io_error(response).await),
169        }
170    }
171
172    async fn login(&mut self, username: &str, password: &str) -> io::Result<LoginResponse> {
173        // TODO(https://github.com/seanmonstar/reqwest/pull/1096): Replace with a basic_auth()
174        // call on the RequestBuilder once it is supported in WASM.
175        let basic_auth =
176            format!("Basic {}", BASE64_STANDARD.encode(format!("{}:{}", username, password)));
177
178        let response = self
179            .client
180            .post(self.make_url("api/login"))
181            .headers(self.default_headers())
182            .header("Authorization", basic_auth)
183            .header("Content-Length", 0)
184            .send()
185            .await
186            .map_err(reqwest_error_to_io_error)?;
187        match response.status() {
188            StatusCode::OK => {
189                let bytes = response.bytes().await.map_err(reqwest_error_to_io_error)?;
190                let response: LoginResponse = serde_json::from_reader(bytes.reader())?;
191                let auth_data = AuthData {
192                    username: username.to_owned(),
193                    access_token: response.access_token.clone(),
194                };
195                *(self.auth_data.borrow_mut()) = Some(auth_data);
196                Ok(response)
197            }
198            _ => Err(http_response_to_io_error(response).await),
199        }
200    }
201
202    async fn logout(&mut self) -> io::Result<()> {
203        let mut auth_data = self.auth_data.borrow_mut();
204        let response = {
205            let auth_data = Self::require_auth_data(auth_data.as_ref())?;
206            self.client
207                .post(self.make_url(&format!("api/users/{}/logout", auth_data.username)))
208                .headers(self.default_headers())
209                .header("Content-Length", 0)
210                .bearer_auth(auth_data.access_token.as_str())
211                .send()
212                .await
213                .map_err(reqwest_error_to_io_error)?
214        };
215        match response.status() {
216            StatusCode::OK => {
217                *auth_data = None;
218                Ok(())
219            }
220            _ => Err(http_response_to_io_error(response).await),
221        }
222    }
223
224    fn is_logged_in(&self) -> bool {
225        self.auth_data.borrow().is_some()
226    }
227
228    fn logged_in_username(&self) -> Option<String> {
229        self.auth_data.borrow().as_ref().map(|x| x.username.to_owned())
230    }
231
232    async fn get_files(&mut self, username: &str) -> io::Result<GetFilesResponse> {
233        let mut builder = self
234            .client
235            .get(self.make_url(&format!("api/users/{}/files", username)))
236            .headers(self.default_headers());
237        if let Some(auth_data) = self.auth_data.borrow().as_ref() {
238            builder = builder.bearer_auth(auth_data.access_token.as_str());
239        }
240        let response = builder.send().await.map_err(reqwest_error_to_io_error)?;
241        match response.status() {
242            StatusCode::OK => {
243                let bytes = response.bytes().await.map_err(reqwest_error_to_io_error)?;
244                let response: GetFilesResponse = serde_json::from_reader(bytes.reader())?;
245                Ok(response)
246            }
247            _ => Err(http_response_to_io_error(response).await),
248        }
249    }
250
251    async fn get_file(
252        &mut self,
253        username: &str,
254        filename: &str,
255        request: &GetFileRequest,
256    ) -> io::Result<GetFileResponse> {
257        let mut builder = self
258            .client
259            .get(self.make_url(&format!("api/users/{}/files/{}", username, filename)))
260            .headers(self.default_headers())
261            .query(&request);
262        if let Some(auth_data) = self.auth_data.borrow().as_ref() {
263            builder = builder.bearer_auth(auth_data.access_token.as_str());
264        }
265        let response = builder.send().await.map_err(reqwest_error_to_io_error)?;
266        match response.status() {
267            StatusCode::OK => {
268                let bytes = response.bytes().await.map_err(reqwest_error_to_io_error)?;
269                let response: GetFileResponse = serde_json::from_reader(bytes.reader())?;
270                Ok(response)
271            }
272            _ => Err(http_response_to_io_error(response).await),
273        }
274    }
275
276    async fn patch_file(
277        &mut self,
278        username: &str,
279        filename: &str,
280        request: &PatchFileRequest,
281    ) -> io::Result<()> {
282        let auth_data = self.auth_data.borrow();
283
284        let response = self
285            .client
286            .patch(self.make_url(&format!("api/users/{}/files/{}", username, filename)))
287            .headers(self.default_headers())
288            .header("Content-Type", "application/json")
289            .body(serde_json::to_vec(&request)?)
290            .bearer_auth(Self::require_auth_data(auth_data.as_ref())?.access_token.as_str())
291            .send()
292            .await
293            .map_err(reqwest_error_to_io_error)?;
294        match response.status() {
295            StatusCode::OK | StatusCode::CREATED => Ok(()),
296            _ => Err(http_response_to_io_error(response).await),
297        }
298    }
299
300    async fn delete_file(&mut self, username: &str, filename: &str) -> io::Result<()> {
301        let auth_data = self.auth_data.borrow();
302
303        let response = self
304            .client
305            .delete(self.make_url(&format!("api/users/{}/files/{}", username, filename)))
306            .headers(self.default_headers())
307            .header("Content-Length", 0)
308            .bearer_auth(Self::require_auth_data(auth_data.as_ref())?.access_token.as_str())
309            .send()
310            .await
311            .map_err(reqwest_error_to_io_error)?;
312        match response.status() {
313            StatusCode::OK => Ok(()),
314            _ => Err(http_response_to_io_error(response).await),
315        }
316    }
317}
318
319#[cfg(test)]
320mod testutils {
321    use super::*;
322    use std::env;
323
324    /// Creates a new service that talks to the configured API service for testing.
325    pub(crate) fn new_service_from_env() -> CloudService {
326        let service_api = env::var("SERVICE_URL").expect("Expected env config not found");
327        CloudService::new(&service_api).unwrap()
328    }
329
330    /// Holds state for a test and allows for automatic cleanup of shared resources.
331    pub(crate) struct TestContext {
332        service: CloudService,
333
334        // State required to automatically clean up files on `drop`.
335        username: Option<String>,
336        files_to_delete: Vec<String>,
337    }
338
339    impl TestContext {
340        /// Creates a new test context that talks to the configured API service.
341        pub(crate) fn new_from_env() -> Self {
342            TestContext { service: new_service_from_env(), username: None, files_to_delete: vec![] }
343        }
344
345        /// Returns the service client in this context.
346        pub(crate) fn service(&self) -> CloudService {
347            self.service.clone()
348        }
349
350        /// Returns the username of the selected test account.
351        pub(crate) fn get_username(&self, i: u8) -> String {
352            env::var(format!("TEST_ACCOUNT_{}_USERNAME", i)).expect("Expected env config not found")
353        }
354
355        /// Authenticates against the server using the username and password passed in via
356        /// environment variables.  We need to support multiple test accounts at the same time, so
357        /// this performs authentication for the test account `i`.
358        ///
359        /// Returns the username of the selected test account for convenience.
360        pub(crate) async fn do_login(&mut self, i: u8) -> String {
361            let username = self.get_username(i);
362            let password = env::var(format!("TEST_ACCOUNT_{}_PASSWORD", i))
363                .expect("Expected env config not found");
364            let _response = self.service.login(&username, &password).await.unwrap();
365            self.username = Some(username.clone());
366            username
367        }
368
369        /// Clears the authentication token to represent a log out.
370        pub(crate) async fn do_logout(&mut self) {
371            self.service.logout().await.unwrap();
372            self.username = None;
373        }
374
375        /// Generates a random filename and its content for testing, and makes sure the file gets
376        /// deleted during cleanup in case the test didn't do it on its own.
377        pub(crate) fn random_file(&mut self) -> (String, String) {
378            let filename = format!("file-{}", rand::random::<u64>());
379            let content = format!("Test content for {}", filename);
380            self.files_to_delete.push(filename.clone());
381            (filename, content)
382        }
383    }
384
385    impl Drop for TestContext {
386        fn drop(&mut self) {
387            #[tokio::main]
388            #[allow(clippy::single_match)]
389            async fn cleanup(context: &mut TestContext) {
390                match context.username.as_ref() {
391                    Some(username) => {
392                        for filename in context.files_to_delete.iter() {
393                            if let Err(e) = context.service.delete_file(username, filename).await {
394                                eprintln!(
395                                    "Failed to delete file {} during cleanup: {}",
396                                    filename, e
397                                );
398                            }
399                        }
400
401                        if let Err(e) = context.service.logout().await {
402                            eprintln!("Failed to log out for {} during cleanup: {}", username, e);
403                        }
404                    }
405                    _ => {
406                        // Nothing to do: if we have a file in context.files_to_delete, it might be
407                        // because we generated its name before authenticating or logging in, but
408                        // in that case, we can't have created the file.
409                    }
410                }
411            }
412            cleanup(self);
413        }
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    //! Tests against the real EndBASIC service.
420    //!
421    //! These tests are configured with the username/password of a test account that we know exists,
422    //! but that limits the kind of testing we can do (especially around authentication/login).
423    //! We could instead mock out the HTTP client to inject arbitrary responses in our tests, which
424    //! would allow us to validate JSON deserialization and the like... but then the tests would be
425    //! so unrealistic as to not be useful.
426
427    use super::testutils::*;
428    use super::*;
429    use std::env;
430
431    #[test]
432    #[ignore = "Requires environment configuration and is expensive"]
433    fn test_login_ok() {
434        #[tokio::main]
435        async fn run(context: &mut TestContext) {
436            let _username = context.do_login(1).await;
437        }
438        run(&mut TestContext::new_from_env());
439    }
440
441    #[tokio::test]
442    #[ignore = "Requires environment configuration and is expensive"]
443    async fn test_login_bad_password() {
444        let username = env::var("TEST_ACCOUNT_1_USERNAME").expect("Expected env config not found");
445        let password = "this is an invalid password for the test account";
446
447        let mut service = new_service_from_env();
448        let err = service.login(&username, &password).await.unwrap_err();
449        assert_eq!(io::ErrorKind::PermissionDenied, err.kind());
450    }
451
452    #[test]
453    #[ignore = "Requires environment configuration and is expensive"]
454    fn test_get_files() {
455        #[tokio::main]
456        async fn run(context: &mut TestContext) {
457            let username = context.do_login(1).await;
458            let mut service = context.service();
459
460            let mut needed_bytes = 0;
461            let mut needed_files = 0;
462            let mut filenames_and_contents = vec![];
463            for _ in 0..5 {
464                let (filename, content) = context.random_file();
465
466                needed_bytes += content.as_bytes().len() as u64;
467                needed_files += 1;
468                filenames_and_contents.push((filename, content));
469            }
470
471            let response = service.get_files(&username).await.unwrap();
472            for (filename, _content) in &filenames_and_contents {
473                assert!(!response.files.iter().any(|x| &x.filename == filename));
474            }
475            let disk_quota: DiskSpace = response.disk_quota.unwrap().into();
476            let disk_free: DiskSpace = response.disk_free.unwrap().into();
477            assert!(disk_quota.bytes() > 0);
478            assert!(disk_quota.files() > 0);
479            assert!(disk_free.bytes() >= needed_bytes, "Not enough space for test run");
480            assert!(disk_free.files() >= needed_files, "Not enough space for test run");
481
482            for (filename, _content) in &filenames_and_contents {
483                let request = GetFileRequest::default().with_get_content();
484                let err = service.get_file(&username, filename, &request).await.unwrap_err();
485                assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
486            }
487
488            for (filename, content) in &filenames_and_contents {
489                let request = PatchFileRequest::default().with_content(content.as_bytes());
490                service.patch_file(&username, filename, &request).await.unwrap();
491            }
492
493            let response = service.get_files(&username).await.unwrap();
494            for (filename, _content) in &filenames_and_contents {
495                assert!(response.files.iter().any(|x| &x.filename == filename));
496            }
497        }
498        run(&mut TestContext::new_from_env());
499    }
500
501    async fn do_get_and_patch_file_test(context: &mut TestContext, filename: &str, content: &[u8]) {
502        let username = context.do_login(1).await;
503        let mut service = context.service();
504
505        let request = PatchFileRequest::default().with_content(content);
506        service.patch_file(&username, filename, &request).await.unwrap();
507
508        let request = GetFileRequest::default().with_get_content();
509        let response = service.get_file(&username, filename, &request).await.unwrap();
510        assert_eq!(content, response.decoded_content().unwrap().unwrap());
511    }
512
513    #[test]
514    #[ignore = "Requires environment configuration and is expensive"]
515    fn test_get_and_patch_file_ok() {
516        #[tokio::main]
517        async fn run(context: &mut TestContext) {
518            let (filename, content) = context.random_file();
519            do_get_and_patch_file_test(context, &filename, content.as_bytes()).await;
520        }
521        run(&mut TestContext::new_from_env());
522    }
523
524    #[test]
525    #[ignore = "Requires environment configuration and is expensive"]
526    fn test_get_and_patch_file_empty_ok() {
527        #[tokio::main]
528        async fn run(context: &mut TestContext) {
529            let (filename, _content) = context.random_file();
530            do_get_and_patch_file_test(context, &filename, &[]).await;
531        }
532        run(&mut TestContext::new_from_env());
533    }
534
535    #[test]
536    #[ignore = "Requires environment configuration and is expensive"]
537    fn test_get_and_patch_file_utf8() {
538        #[tokio::main]
539        async fn run(context: &mut TestContext) {
540            let (filename, _content) = context.random_file();
541            let content = "안녕하세요";
542            do_get_and_patch_file_test(context, &filename, content.as_bytes()).await;
543        }
544        run(&mut TestContext::new_from_env());
545    }
546
547    #[test]
548    #[ignore = "Requires environment configuration and is expensive"]
549    fn test_get_file_not_found() {
550        #[tokio::main]
551        async fn run(context: &mut TestContext) {
552            let username = context.do_login(1).await;
553            let mut service = context.service();
554            let (filename, _content) = context.random_file();
555
556            let request = GetFileRequest::default().with_get_content();
557            let err = service.get_file(&username, &filename, &request).await.unwrap_err();
558            assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
559        }
560        run(&mut TestContext::new_from_env());
561    }
562
563    #[test]
564    #[ignore = "Requires environment configuration and is expensive"]
565    fn test_patch_file_without_login() {
566        #[tokio::main]
567        async fn run(context: &mut TestContext) {
568            let mut service = context.service();
569            let username = context.get_username(1);
570
571            context.do_login(1).await;
572            let (filename, _content) = context.random_file();
573
574            context.do_logout().await;
575            let request = PatchFileRequest::default().with_content("foo");
576            let err = service.patch_file(&username, &filename, &request).await.unwrap_err();
577            assert_eq!(io::ErrorKind::PermissionDenied, err.kind(), "{}", err);
578            assert!(format!("{}", err).contains("Not logged in"));
579        }
580        run(&mut TestContext::new_from_env());
581    }
582
583    #[test]
584    #[ignore = "Requires environment configuration and is expensive"]
585    fn test_acls_private() {
586        #[tokio::main]
587        async fn run(context: &mut TestContext) {
588            let mut service = context.service();
589
590            let (filename, content) = context.random_file();
591
592            let username1 = context.get_username(1);
593            let username2 = context.get_username(2);
594
595            // Share username1's file with username2.
596            let request = PatchFileRequest::default().with_content(content.clone());
597            context.do_login(1).await;
598            service.patch_file(&username1, &filename, &request).await.unwrap();
599
600            // Read username1's file as username2 before it is shared.
601            context.do_login(2).await;
602            let request = GetFileRequest::default().with_get_content();
603            let err = service.get_file(&username1, &filename, &request).await.unwrap_err();
604            assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
605
606            // Share username1's file with username2.
607            context.do_login(1).await;
608            let request = PatchFileRequest::default().with_add_readers([username2]);
609            service.patch_file(&username1, &filename, &request).await.unwrap();
610
611            // Read username1's file as username2 again, now that it is shared.
612            context.do_login(2).await;
613            let request = GetFileRequest::default().with_get_content();
614            let response = service.get_file(&username1, &filename, &request).await.unwrap();
615            assert_eq!(content.as_bytes(), response.decoded_content().unwrap().unwrap());
616        }
617        run(&mut TestContext::new_from_env());
618    }
619
620    #[test]
621    #[ignore = "Requires environment configuration and is expensive"]
622    fn test_acls_public() {
623        #[tokio::main]
624        async fn run(context: &mut TestContext) {
625            let mut service = context.service();
626
627            let (filename, content) = context.random_file();
628
629            let username1 = context.get_username(1);
630
631            // Share username1's file with the public.
632            let request = PatchFileRequest::default().with_content(content.clone());
633            context.do_login(1).await;
634            service.patch_file(&username1, &filename, &request).await.unwrap();
635
636            // Read username1's file as a guest before it is shared.
637            context.do_logout().await;
638            let request = GetFileRequest::default().with_get_content();
639            let err = service.get_file(&username1, &filename, &request).await.unwrap_err();
640            assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
641
642            // Share username1's file with the public.
643            context.do_login(1).await;
644            let request = PatchFileRequest::default().with_add_readers(["public".to_owned()]);
645            service.patch_file(&username1, &filename, &request).await.unwrap();
646
647            // Read username1's file as a guest again, now that it is shared.
648            context.do_logout().await;
649            let request = GetFileRequest::default().with_get_content();
650            let response = service.get_file(&username1, &filename, &request).await.unwrap();
651            assert_eq!(content.as_bytes(), response.decoded_content().unwrap().unwrap());
652        }
653        run(&mut TestContext::new_from_env());
654    }
655
656    #[test]
657    #[ignore = "Requires environment configuration and is expensive"]
658    fn test_delete_file_ok() {
659        #[tokio::main]
660        async fn run(context: &mut TestContext) {
661            let username = context.do_login(1).await;
662            let mut service = context.service();
663            let (filename, content) = context.random_file();
664
665            let request = PatchFileRequest::default().with_content(content);
666            service.patch_file(&username, &filename, &request).await.unwrap();
667
668            service.delete_file(&username, &filename).await.unwrap();
669
670            let request = GetFileRequest::default().with_get_content();
671            let err = service.get_file(&username, &filename, &request).await.unwrap_err();
672            assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
673            assert!(format!("{}", err).contains("(server code: 404"));
674        }
675        run(&mut TestContext::new_from_env());
676    }
677
678    #[test]
679    #[ignore = "Requires environment configuration and is expensive"]
680    fn test_delete_file_not_found() {
681        #[tokio::main]
682        async fn run(context: &mut TestContext) {
683            let username = context.do_login(1).await;
684            let mut service = context.service();
685            let (filename, _content) = context.random_file();
686
687            let err = service.delete_file(&username, &filename).await.unwrap_err();
688            assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
689            assert!(format!("{}", err).contains("(server code: 404"));
690        }
691        run(&mut TestContext::new_from_env());
692    }
693
694    #[test]
695    #[ignore = "Requires environment configuration and is expensive"]
696    fn test_delete_file_without_login() {
697        #[tokio::main]
698        async fn run(context: &mut TestContext) {
699            let mut service = context.service();
700            let username = context.get_username(1);
701
702            context.do_login(1).await;
703            let (filename, _content) = context.random_file();
704
705            context.do_logout().await;
706            let err = service.delete_file(&username, &filename).await.unwrap_err();
707            assert_eq!(io::ErrorKind::PermissionDenied, err.kind(), "{}", err);
708            assert!(format!("{}", err).contains("Not logged in"));
709        }
710        run(&mut TestContext::new_from_env());
711    }
712}