testcontainers_modules/gitea/
mod.rs

1/// Self-hosted git server with https/http/ssh access, uses [Gitea](https://docs.gitea.com/).
2use std::result::Result;
3
4use rcgen::{BasicConstraints, CertificateParams, IsCa, Issuer, KeyPair};
5use testcontainers::{
6    core::{
7        wait::HttpWaitStrategy, CmdWaitFor, ContainerPort, ContainerState, ExecCommand, WaitFor,
8    },
9    CopyDataSource, CopyToContainer, Image, TestcontainersError,
10};
11
12/// Container port for SSH listener.
13pub const GITEA_SSH_PORT: ContainerPort = ContainerPort::Tcp(2222);
14/// Container port for HTTPS/HTTP listener.
15pub const GITEA_HTTP_PORT: ContainerPort = ContainerPort::Tcp(3000);
16/// Container port for HTTP listener to redirect call to HTTPS port.
17pub const GITEA_HTTP_REDIRECT_PORT: ContainerPort = ContainerPort::Tcp(3080);
18
19/// Default admin username.
20pub const GITEA_DEFAULT_ADMIN_USERNAME: &str = "git-admin";
21/// Default admin password.
22pub const GITEA_DEFAULT_ADMIN_PASSWORD: &str = "git-admin";
23
24/// Container folder where configuration and SSL certificates are stored to.
25pub const GITEA_CONFIG_FOLDER: &str = "/etc/gitea";
26/// Container folder with git data: repos, DB, etc.
27pub const GITEA_DATA_FOLDER: &str = "/var/lib/gitea";
28
29/// Docker hub registry with gitea image.
30const GITEA_IMAGE_NAME: &str = "gitea/gitea";
31/// Image tag to use.
32const GITEA_IMAGE_TAG: &str = "1.22.3-rootless";
33
34/// File name with SSL certificate.
35const TLS_CERT_FILE_NAME: &str = "cert.pem";
36/// File name with a private key for SSL certificate.
37const TLS_KEY_FILE_NAME: &str = "key.pem";
38/// File name with a Gitea config.
39const CONFIG_FILE_NAME: &str = "app.ini";
40
41/// Module to work with [Gitea](https://docs.gitea.com/) container.
42///
43/// Starts an instance of [`Gitea`](https://docs.gitea.com/), fully functional git server, with reasonable defaults
44/// and possibility to tune some configuration options.
45///
46/// From the `Gitea` documentation:
47/// _Gitea is a painless, self-hosted, all-in-one software development service.
48/// It includes Git hosting, code review, team collaboration, package registry,
49/// and CI/CD. It is similar to GitHub, Bitbucket and GitLab._
50///
51/// By default, `Gitea` server container starts with the following config:
52/// - accepts SSH (Git) protocol requests on port [GITEA_SSH_PORT];
53/// - accepts HTTP requests on port [GITEA_HTTP_PORT];
54/// - has a single configured user with admin privileges,
55///   with pre-defined [username](GITEA_DEFAULT_ADMIN_USERNAME) and [password](GITEA_DEFAULT_ADMIN_PASSWORD);
56/// - configured git server hostname is `localhost`; this is a name which `Gitea` uses in the links to repositories;
57/// - no repositories are created.
58///
59/// Additionally to defaults, it's possible to:
60/// - use HTTPS instead of HTTP with auto-generated self-signed certificate or provide your own certificate;
61/// - redirect HTTP calls to HTTPS listener, if HTTPS is enabled;
62/// - change git server hostname, which is used in various links to repos or web-server;
63/// - provide your own admin user credentials as well as its SSH public key to authorize git calls;
64/// - create any number of public or private repositories with provided names during server startup;
65/// - execute set of `gitea admin ...` commands during server startup to customize configuration;
66/// - add environment variables
67///
68/// # Examples
69///
70/// 1. Minimalistic server
71/// ```rust
72/// use testcontainers::{runners::AsyncRunner, ImageExt};
73/// use testcontainers_modules::gitea::{self, Gitea, GiteaRepo};
74///
75/// #[tokio::test]
76/// async fn default_gitea_server() {
77///     // Run default container
78///     let gitea = Gitea::default().start().await.unwrap();
79///     let port = gitea
80///         .get_host_port_ipv4(gitea::GITEA_HTTP_PORT)
81///         .await
82///         .unwrap();
83///     let url = format!(
84///         "http://localhost:{port}/api/v1/users/{}",
85///         gitea::GITEA_DEFAULT_ADMIN_USERNAME
86///     );
87///
88///     // Anonymous query Gitea API for user info
89///     let response = reqwest::Client::new().get(url).send().await.unwrap();
90///     assert_eq!(response.status(), 200);
91/// }
92/// ```
93///
94/// 2. Customized server
95/// ```rust
96/// use testcontainers::{runners::AsyncRunner, ImageExt};
97/// use testcontainers_modules::gitea::{self, Gitea, GiteaRepo};
98///
99/// #[tokio::test]
100/// async fn gitea_server_with_custom_config() {
101///     // Start server container with:
102///     // - custom admin credentials
103///     // - two repos: public and private
104///     // - TLS enabled
105///     // - port mapping for HTTP and SSH
106///     // - custom git hostname
107///     let gitea = Gitea::default()
108///         .with_git_hostname("gitea.example.com")
109///         .with_admin_account("custom-admin", "password", None)
110///         .with_repo(GiteaRepo::Public("public-test-repo".to_string()))
111///         .with_repo(GiteaRepo::Private("private-test-repo".to_string()))
112///         .with_tls(true)
113///         .with_mapped_port(443, gitea::GITEA_HTTP_PORT)
114///         .with_mapped_port(22, gitea::GITEA_SSH_PORT)
115///         .start()
116///         .await
117///         .unwrap();
118///
119///     // Obtain auto-created root CA certificate
120///     let ca = gitea.image().tls_ca().unwrap();
121///     let ca = reqwest::Certificate::from_pem(ca.as_bytes()).unwrap();
122///     // Attach custom CA to the client
123///     let client = reqwest::ClientBuilder::new()
124///         .add_root_certificate(ca)
125///         .build()
126///         .unwrap();
127///
128///     // Get list of repos of particular user.
129///     // This query should be authorized.
130///     let response = client
131///         .get("https://localhost/api/v1/user/repos")
132///         .basic_auth("custom-admin", Some("password"))
133///         .header("Host", "gitea.example.com")
134///         .send()
135///         .await
136///         .unwrap();
137///     assert_eq!(response.status(), 200);
138///
139///     let repos = response.json::<serde_json::Value>().await.unwrap();
140///     assert_eq!(repos.as_array().unwrap().len(), 2);
141/// }
142/// ```
143#[derive(Debug, Clone)]
144pub struct Gitea {
145    git_hostname: String,
146    admin_username: String,
147    admin_password: String,
148    admin_key: Option<String>,
149    admin_commands: Vec<Vec<String>>,
150    tls: Option<GiteaTlsCert>,
151    repos: Vec<GiteaRepo>,
152    copy_to_sources: Vec<CopyToContainer>,
153}
154
155impl Default for Gitea {
156    /// Returns default Gitea server setup with the following defaults:
157    /// - hostname is `localhost`;
158    /// - admin account username from [GITEA_DEFAULT_ADMIN_USERNAME];
159    /// - admin account password from [GITEA_DEFAULT_ADMIN_PASSWORD];
160    /// - without admins' account SSH public key;
161    /// - without additional startup admin commands;
162    /// - without TLS (SSH and HTTP protocols only);
163    /// - without repositories.
164    fn default() -> Self {
165        Self {
166            git_hostname: "localhost".to_string(),
167            admin_username: GITEA_DEFAULT_ADMIN_USERNAME.to_string(),
168            admin_password: GITEA_DEFAULT_ADMIN_PASSWORD.to_string(),
169            admin_key: None,
170            admin_commands: vec![],
171            tls: None,
172            repos: vec![],
173            copy_to_sources: vec![Self::render_app_ini("http", "localhost", false)],
174        }
175    }
176}
177
178impl Image for Gitea {
179    fn name(&self) -> &str {
180        GITEA_IMAGE_NAME
181    }
182
183    fn tag(&self) -> &str {
184        GITEA_IMAGE_TAG
185    }
186
187    fn ready_conditions(&self) -> Vec<WaitFor> {
188        let http_check = match self.tls {
189            Some(_) => WaitFor::seconds(5), // it's expensive to add reqwest dependency for the single health check only
190            None => WaitFor::http(
191                HttpWaitStrategy::new("/api/swagger")
192                    .with_port(GITEA_HTTP_PORT)
193                    .with_expected_status_code(200_u16),
194            ),
195        };
196
197        vec![
198            WaitFor::message_on_stdout(format!(
199                "Starting new Web server: tcp:0.0.0.0:{}",
200                GITEA_HTTP_PORT.as_u16()
201            )),
202            http_check,
203        ]
204    }
205
206    fn copy_to_sources(&self) -> impl IntoIterator<Item = &CopyToContainer> {
207        &self.copy_to_sources
208    }
209
210    fn expose_ports(&self) -> &[ContainerPort] {
211        if self.tls.is_some() {
212            // additional port for HTTP with redirect to HTTPS
213            &[GITEA_SSH_PORT, GITEA_HTTP_PORT, GITEA_HTTP_REDIRECT_PORT]
214        } else {
215            &[GITEA_SSH_PORT, GITEA_HTTP_PORT]
216        }
217    }
218
219    fn exec_after_start(
220        &self,
221        _cs: ContainerState,
222    ) -> Result<Vec<ExecCommand>, TestcontainersError> {
223        // Create admin user
224        let mut start_commands = vec![self.create_admin_user_cmd()];
225        // Add admins' public key if needed
226        if let Some(key) = &self.admin_key {
227            start_commands.push(self.create_admin_key_cmd(key));
228        }
229        // create repos if they're defined
230        self.repos.iter().for_each(|r| {
231            start_commands.push(self.create_repo_cmd(r));
232        });
233
234        // and finally, add `gitea admin` commands, if defined
235        let admin_commands: Vec<Vec<String>> = self
236            .admin_commands
237            .clone()
238            .into_iter()
239            .map(|v| {
240                vec!["gitea".to_string(), "admin".to_string()]
241                    .into_iter()
242                    .chain(v)
243                    .collect::<Vec<String>>()
244            })
245            .collect();
246
247        // glue everything togather
248        start_commands.extend(admin_commands);
249
250        // and convert to `ExecCommand`s
251        let commands: Vec<ExecCommand> = start_commands
252            .iter()
253            .map(|v| ExecCommand::new(v).with_cmd_ready_condition(CmdWaitFor::exit_code(0)))
254            .collect();
255
256        Ok(commands)
257    }
258}
259
260impl Gitea {
261    /// Change admin user credential to the custom provided `username` and `password` instead of using defaults.
262    ///
263    /// If `public_key` value is provided, it will be added to the admin account.
264    ///
265    /// # Example
266    /// ```rust,ignore
267    /// #[tokio::test]
268    /// async fn test() {
269    ///     const PUB_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJRE5a67/cTbR6DpWqzBl6BTY0LE0Hg715ZI/FMK7iCH";
270    ///     let gitea = Gitea::default()
271    ///             .with_admin_account("git-admin", "nKz4SC7bkz4KSXbQ", Some(PUB_KEY))
272    ///             .start()
273    ///             .await
274    ///             .unwrap();
275    /// // ...
276    /// }
277    /// ```
278    pub fn with_admin_account(
279        self,
280        username: impl Into<String>,
281        password: impl Into<String>,
282        public_key: Option<String>,
283    ) -> Self {
284        Self {
285            admin_username: username.into(),
286            admin_password: password.into(),
287            admin_key: public_key,
288            ..self
289        }
290    }
291
292    /// Set git server hostname instead of the default `localhost`.
293    ///
294    /// This is not a containers' hostname, but the name which git server uses in various links like repo URLs.
295    pub fn with_git_hostname(self, hostname: impl Into<String>) -> Self {
296        let new = Self {
297            git_hostname: hostname.into(),
298            ..self
299        };
300        Self {
301            // to update app.ini
302            copy_to_sources: new.generate_copy_to_sources(),
303            ..new
304        }
305    }
306
307    /// Create a repository during startup.
308    ///
309    /// It's possible to call this method more than once to create several repositories.
310    ///
311    /// # Example
312    /// ```rust,ignore
313    /// #[tokio::test]
314    /// async fn test() {
315    ///     let gitea = Gitea::default()
316    ///             .with_repo(GiteaRepo::Public("example-public-repo"))
317    ///             .with_repo(GiteaRepo::Private("example-private-repo"))
318    ///             .start()
319    ///             .await
320    ///             .unwrap();
321    /// // ...
322    /// }
323    /// ```
324    pub fn with_repo(self, repo: GiteaRepo) -> Self {
325        let mut repos = self.repos;
326        repos.push(repo);
327        Self { repos, ..self }
328    }
329
330    /// Add `gitea admin ...` command with parameters to execute after server startup.
331    ///
332    /// This method is useful, for example, to create additional users or to do other admin stuff.
333    ///
334    /// It's possible to call this method more than once to add several consecutive commands.
335    ///
336    /// # Example
337    /// ```rust,ignore
338    /// #[tokio::test]
339    /// async fn test() {
340    ///     let cmd = vec![
341    ///          "user",
342    ///          "create",
343    ///          "--username",
344    ///          "test-user",
345    ///          "--password",
346    ///          "test-password",
347    ///          "--email",
348    ///          "test@localhost",
349    ///          "--must-change-password=true",
350    ///          ]
351    ///          .into_iter()
352    ///          .map(String::from)
353    ///          .collect::<Vec<String>>();
354    ///
355    ///     let gitea = Gitea::default()
356    ///         .with_admin_command(command)
357    ///         .start()
358    ///         .await
359    ///         .unwrap();
360    /// // ...
361    /// }
362    /// ```
363    pub fn with_admin_command(self, command: impl IntoIterator<Item = impl Into<String>>) -> Self {
364        let command = command
365            .into_iter()
366            .map(|s| s.into())
367            .collect::<Vec<String>>();
368        let mut admin_commands = self.admin_commands;
369
370        admin_commands.push(command);
371        Self {
372            admin_commands,
373            ..self
374        }
375    }
376
377    /// `Gitea` web server will start with HTTPS listener (with auto-generated certificate),
378    /// instead of the default HTTP.
379    ///
380    /// If `enabled` is `true,` web server will be started with TLS listener with auto-generated self-signed certificate.
381    /// If Root CA certificate is needed to ensure fully protected communications,
382    /// it can be obtained by [Gitea::tls_ca()] method call.
383    ///
384    /// Note: _If TLS is enabled, additional HTTP listener will be started on port [GITEA_HTTP_REDIRECT_PORT]
385    /// to redirect all HTTP calls to the HTTPS listener._
386    pub fn with_tls(self, enabled: bool) -> Self {
387        let new = Self {
388            tls: if enabled {
389                Some(GiteaTlsCert::default())
390            } else {
391                None
392            },
393            ..self
394        };
395
396        Self {
397            // to update app.ini and certificates
398            copy_to_sources: new.generate_copy_to_sources(),
399            ..new
400        }
401    }
402
403    /// `Gitea` web server will start with HTTPS listener (with provided certificate), instead of the default HTTP.
404    ///
405    /// `cert` and `key` are strings with PEM encoded certificate and its key.
406    /// This method is similar to [Gitea::with_tls()] but use provided certificate instead of generating self-signed one.
407    ///
408    /// Note: _If TLS is enabled, additional HTTP listener will be started on port [GITEA_HTTP_REDIRECT_PORT]
409    /// to redirect all HTTP calls to the HTTPS listener._
410    pub fn with_tls_certs(self, cert: impl Into<String>, key: impl Into<String>) -> Self {
411        let new = Self {
412            tls: Some(GiteaTlsCert::from_pem(cert.into(), key.into())),
413            ..self
414        };
415
416        Self {
417            // to update app.ini and certificates
418            copy_to_sources: new.generate_copy_to_sources(),
419            ..new
420        }
421    }
422
423    /// Return PEM encoded Root CA certificate of the Gitea servers' certificate issuer.
424    ///
425    /// If TLS has been enabled using [Gitea::with_tls_certs()] method (with auto-generated self-signed certificate),
426    /// then this method returns `Some` option with issuer root CA certificate to verify servers' certificate
427    /// and ensure fully protected communications.
428    ///
429    /// If TLS isn't enabled or TLS is enabled with external certificate,
430    /// provided using [Gitea::with_tls_certs] method,
431    /// this method returns `None` since there is no known CA certificate.
432    pub fn tls_ca(&self) -> Option<&str> {
433        self.tls.as_ref().and_then(|t| t.ca())
434    }
435
436    /// Gather app.ini and certificates (if needed) into one vector to store into modules' structure.
437    fn generate_copy_to_sources(&self) -> Vec<CopyToContainer> {
438        let mut to_copy = vec![];
439
440        // Prepare app.ini from template
441        let app_ini = Self::render_app_ini(
442            self.protocol(),
443            self.git_hostname.as_str(),
444            self.tls.is_some(),
445        );
446        to_copy.push(app_ini);
447
448        // Add certificates if TLS is enabled
449        if let Some(tls_config) = &self.tls {
450            let cert = CopyToContainer::new(
451                CopyDataSource::Data(tls_config.cert.clone().into_bytes()),
452                format!("{GITEA_CONFIG_FOLDER}/{TLS_CERT_FILE_NAME}"),
453            );
454            let key = CopyToContainer::new(
455                CopyDataSource::Data(tls_config.key.clone().into_bytes()),
456                format!("{GITEA_CONFIG_FOLDER}/{TLS_KEY_FILE_NAME}"),
457            );
458            to_copy.push(cert);
459            to_copy.push(key);
460        }
461
462        to_copy
463    }
464
465    /// Render app.ini content from the template using current config values.
466    fn render_app_ini(protocol: &str, hostname: &str, is_tls: bool) -> CopyToContainer {
467        let redirect_port = GITEA_HTTP_REDIRECT_PORT.as_u16();
468        // load template of the app.ini,
469        // `[server]` section should be at the bottom to add variable part
470        // and TLS-related variables is needed
471        let mut app_ini_template = include_str!("app.ini").to_string();
472        let host_template_part = format!(
473            r#"
474DOMAIN = {hostname}
475SSH_DOMAIN = {hostname}
476ROOT_URL = {protocol}://{hostname}/
477PROTOCOL = {protocol}
478"#,
479        );
480        app_ini_template.push_str(&host_template_part);
481
482        // If TLS is enabled, add TLS-related config to app.ini
483        if is_tls {
484            let tls_config = format!(
485                r#"
486CERT_FILE = {GITEA_CONFIG_FOLDER}/{TLS_CERT_FILE_NAME}
487KEY_FILE = {GITEA_CONFIG_FOLDER}/{TLS_KEY_FILE_NAME}
488REDIRECT_OTHER_PORT = true
489PORT_TO_REDIRECT = {redirect_port}
490"#
491            );
492            app_ini_template.push_str(&tls_config);
493        }
494
495        CopyToContainer::new(
496            CopyDataSource::Data(app_ini_template.into_bytes()),
497            format!("{GITEA_CONFIG_FOLDER}/{CONFIG_FILE_NAME}"),
498        )
499    }
500
501    /// Generate command to create admin user with actual parameters.
502    fn create_admin_user_cmd(&self) -> Vec<String> {
503        vec![
504            "gitea",
505            "admin",
506            "user",
507            "create",
508            "--username",
509            self.admin_username.as_str(),
510            "--password",
511            self.admin_password.as_str(),
512            "--email",
513            format!("{}@localhost", self.admin_username).as_str(),
514            "--admin",
515        ]
516        .into_iter()
517        .map(String::from)
518        .collect::<Vec<String>>()
519    }
520
521    /// Generate curl command with API call to add public key for admin user.
522    fn create_admin_key_cmd(&self, key: &String) -> Vec<String> {
523        let body = format!(r#"{{"title":"default","key":"{key}","read_only":false}}"#);
524        self.create_gitea_api_curl_cmd("POST", "/user/keys", Some(body))
525    }
526
527    /// Generate curl command with API call to create repository with minimal parameters.
528    fn create_repo_cmd(&self, repo: &GiteaRepo) -> Vec<String> {
529        let (repo, private) = match repo {
530            GiteaRepo::Private(name) => (name, "true"),
531            GiteaRepo::Public(name) => (name, "false"),
532        };
533
534        let body = format!(
535            r#"{{"name":"{repo}","readme":"Default","auto_init":true,"private":{private}}}"#,
536        );
537
538        self.create_gitea_api_curl_cmd("POST", "/user/repos", Some(body))
539    }
540
541    /// Helper to generate curl commands with API call.
542    fn create_gitea_api_curl_cmd(
543        &self,
544        method: &str,
545        api_path: &str,
546        body: Option<String>,
547    ) -> Vec<String> {
548        let mut curl = vec![
549            "curl",
550            "-sk",
551            "-X",
552            method,
553            "-H",
554            "accept: application/json",
555            "-H",
556            "Content-Type: application/json",
557            "-u",
558            format!("{}:{}", self.admin_username, self.admin_password).as_str(),
559        ]
560        .into_iter()
561        .map(String::from)
562        .collect::<Vec<String>>();
563
564        // add body if present
565        if let Some(body) = body {
566            curl.push("-d".to_string());
567            curl.push(body);
568        }
569
570        // and finally, add url to API with a requested path
571        curl.push(self.api_url(api_path));
572
573        curl
574    }
575
576    /// Return configured protocol string.
577    fn protocol(&self) -> &str {
578        if self.tls.is_some() {
579            "https"
580        } else {
581            "http"
582        }
583    }
584
585    /// Return container-internal base URL to the API.
586    fn api_url(&self, api: &str) -> String {
587        let api = api.strip_prefix('/').unwrap_or(api);
588        format!(
589            "{}://localhost:{}/api/v1/{api}",
590            self.protocol(),
591            GITEA_HTTP_PORT.as_u16()
592        )
593    }
594}
595
596/// Defines repository to create during container startup.
597///
598/// Each option includes repository name in the enum value.
599///
600/// [`Gitea::with_repo`] documentation provides more details and usage examples.
601#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
602pub enum GiteaRepo {
603    /// Create a private repository which is accessible with authorization only.
604    Private(String),
605    /// Create a public repository accessible without authorization.
606    Public(String),
607}
608
609/// Helper struct to store TLS certificates.
610#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
611struct GiteaTlsCert {
612    cert: String,
613    key: String,
614    ca: Option<String>,
615}
616
617impl Default for GiteaTlsCert {
618    fn default() -> Self {
619        Self::new("localhost")
620    }
621}
622
623impl GiteaTlsCert {
624    /// Generate new self-signed Root CA certificate,
625    /// and generate new server certificate signed by CA.
626    ///
627    /// SAN list includes "localhost", "127.0.0.1", "::1"
628    /// and provided hostname (if it's different form localhost).
629    fn new(hostname: impl Into<String>) -> Self {
630        // generate root CA key and cert
631        let ca_key = KeyPair::generate().unwrap();
632        let mut ca_cert = CertificateParams::new(vec!["Gitea root CA".to_string()]).unwrap();
633        ca_cert.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
634
635        // prepare SANs
636        let mut hostnames = vec![
637            "localhost".to_string(),
638            "127.0.0.1".to_string(),
639            "::1".to_string(),
640        ];
641        let hostname = hostname.into();
642        if hostname != "localhost" {
643            hostnames.insert(0, hostname);
644        }
645
646        // and generate server key and cert
647        let key = KeyPair::generate().unwrap();
648        let issuer = Issuer::from_params(&ca_cert, &ca_key);
649        let cert = CertificateParams::new(hostnames)
650            .unwrap()
651            .signed_by(&key, &issuer)
652            .unwrap();
653
654        let ca_cert = ca_cert.self_signed(&ca_key).unwrap();
655        Self {
656            cert: cert.pem(),
657            key: key.serialize_pem(),
658            ca: Some(ca_cert.pem()),
659        }
660    }
661
662    /// Construct from externally provided certificate and key, without CA.
663    fn from_pem(cert: impl Into<String>, key: impl Into<String>) -> Self {
664        Self {
665            cert: cert.into(),
666            key: key.into(),
667            ca: None,
668        }
669    }
670
671    /// Return self-signed Root CA is it was generated.
672    fn ca(&self) -> Option<&str> {
673        self.ca.as_deref()
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use reqwest::Certificate;
680    use serde_json::Value;
681    use testcontainers::{runners::AsyncRunner, ContainerAsync};
682
683    use super::*;
684
685    const TEST_PUBLIC_KEY: &str =
686        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJRE5a67/cTbR6DpWqzBl6BTY0LE0Hg715ZI/FMK7iCH";
687    const TEST_ADMIN_USERNAME: &str = "non-default-user";
688    const TEST_ADMIN_PASSWORD: &str = "some-dummy-password";
689    const TEST_PUBLIC_REPO: &str = "test-public-repo";
690    const TEST_PRIVATE_REPO: &str = "test-private-repo";
691
692    async fn api_url(container: &ContainerAsync<Gitea>, api: &str) -> String {
693        let api = api.strip_prefix('/').unwrap_or(api);
694        let host = container.get_host().await.unwrap();
695        let port = container.get_host_port_ipv4(GITEA_HTTP_PORT).await.unwrap();
696
697        format!(
698            "{}://{host}:{port}/api/v1/{api}",
699            container.image().protocol(),
700        )
701    }
702
703    #[tokio::test]
704    async fn gitea_defaults() {
705        let gitea = Gitea::default().start().await.unwrap();
706
707        // Check for admin user
708        let response = reqwest::Client::new()
709            .get(api_url(&gitea, &format!("/users/{GITEA_DEFAULT_ADMIN_USERNAME}")).await)
710            .basic_auth(
711                GITEA_DEFAULT_ADMIN_USERNAME,
712                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
713            )
714            .send()
715            .await
716            .unwrap();
717
718        assert_eq!(response.status(), 200);
719
720        // Check for an admin user public key
721        let keys_list = reqwest::Client::new()
722            .get(api_url(&gitea, "/user/keys").await)
723            .basic_auth(
724                GITEA_DEFAULT_ADMIN_USERNAME,
725                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
726            )
727            .send()
728            .await
729            .unwrap()
730            .json::<Value>()
731            .await
732            .unwrap();
733
734        let keys_list = keys_list.as_array().unwrap();
735        assert!(keys_list.is_empty());
736    }
737
738    #[tokio::test]
739    async fn gitea_with_tls() {
740        let gitea = Gitea::default().with_tls(true).start().await.unwrap();
741
742        // Check w/o CA, should fail
743        let response = reqwest::Client::new()
744            .get(api_url(&gitea, &format!("/users/{GITEA_DEFAULT_ADMIN_USERNAME}")).await)
745            .basic_auth(
746                GITEA_DEFAULT_ADMIN_USERNAME,
747                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
748            )
749            .send()
750            .await;
751        assert!(response.is_err());
752
753        // Check with CA, should pass
754        let ca = gitea.image().tls_ca().unwrap();
755        let ca = Certificate::from_pem(ca.as_bytes()).unwrap();
756        let client = reqwest::ClientBuilder::new()
757            .use_rustls_tls()
758            .add_root_certificate(ca)
759            .build()
760            .unwrap();
761
762        let response = client
763            .get(api_url(&gitea, &format!("/users/{GITEA_DEFAULT_ADMIN_USERNAME}")).await)
764            .basic_auth(
765                GITEA_DEFAULT_ADMIN_USERNAME,
766                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
767            )
768            .send()
769            .await
770            .unwrap();
771        assert_eq!(response.status(), 200);
772    }
773
774    #[tokio::test]
775    async fn gitea_custom_admin_credentials() {
776        let gitea = Gitea::default()
777            .with_admin_account(
778                TEST_ADMIN_USERNAME,
779                TEST_ADMIN_PASSWORD,
780                Some(TEST_PUBLIC_KEY.to_string()),
781            )
782            .start()
783            .await
784            .unwrap();
785
786        // Check for an admin user public key with default credentials,
787        // fails since user doesn't exist
788        let response = reqwest::Client::new()
789            .get(api_url(&gitea, "/user/keys").await)
790            .basic_auth(
791                GITEA_DEFAULT_ADMIN_USERNAME,
792                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
793            )
794            .send()
795            .await
796            .unwrap();
797
798        assert_eq!(response.status(), 401);
799
800        // The same check with custom credentials should pass
801        let keys_list = reqwest::Client::new()
802            .get(api_url(&gitea, "/user/keys").await)
803            .basic_auth(TEST_ADMIN_USERNAME, Some(TEST_ADMIN_PASSWORD))
804            .send()
805            .await
806            .unwrap()
807            .json::<Value>()
808            .await
809            .unwrap();
810
811        let keys_list = keys_list.as_array().unwrap();
812        assert_eq!(keys_list.len(), 1);
813    }
814
815    #[tokio::test]
816    async fn gitea_create_repos() {
817        let gitea = Gitea::default()
818            .with_repo(GiteaRepo::Public(TEST_PUBLIC_REPO.to_string()))
819            .with_repo(GiteaRepo::Private(TEST_PRIVATE_REPO.to_string()))
820            .start()
821            .await
822            .unwrap();
823
824        // Check access to the public repo w/o auth
825        let response = reqwest::Client::new()
826            .get(
827                api_url(
828                    &gitea,
829                    &format!("/repos/{GITEA_DEFAULT_ADMIN_USERNAME}/{TEST_PUBLIC_REPO}"),
830                )
831                .await,
832            )
833            .send()
834            .await
835            .unwrap();
836
837        assert_eq!(response.status(), 200);
838
839        // Check access to the private repo w/o auth,
840        // should be 404
841        let response = reqwest::Client::new()
842            .get(
843                api_url(
844                    &gitea,
845                    &format!("/repos/{GITEA_DEFAULT_ADMIN_USERNAME}/{TEST_PRIVATE_REPO}"),
846                )
847                .await,
848            )
849            .send()
850            .await
851            .unwrap();
852
853        assert_eq!(response.status(), 404);
854
855        // Check access to the private repo with auth,
856        // should be 200
857        let response = reqwest::Client::new()
858            .get(
859                api_url(
860                    &gitea,
861                    &format!("/repos/{GITEA_DEFAULT_ADMIN_USERNAME}/{TEST_PRIVATE_REPO}"),
862                )
863                .await,
864            )
865            .basic_auth(
866                GITEA_DEFAULT_ADMIN_USERNAME,
867                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
868            )
869            .send()
870            .await
871            .unwrap();
872
873        assert_eq!(response.status(), 200);
874    }
875
876    #[tokio::test]
877    async fn gitea_admin_commands() {
878        let command = vec![
879            "user",
880            "create",
881            "--username",
882            TEST_ADMIN_USERNAME,
883            "--password",
884            TEST_ADMIN_PASSWORD,
885            "--email",
886            format!("{}@localhost", TEST_ADMIN_USERNAME).as_str(),
887            "--must-change-password=false",
888        ]
889        .into_iter()
890        .map(String::from)
891        .collect::<Vec<String>>();
892
893        let gitea = Gitea::default()
894            .with_admin_command(command)
895            .start()
896            .await
897            .unwrap();
898
899        // Check for new custom user
900        let response = reqwest::Client::new()
901            .get(api_url(&gitea, &format!("/users/{TEST_ADMIN_USERNAME}")).await)
902            .basic_auth(
903                GITEA_DEFAULT_ADMIN_USERNAME,
904                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
905            )
906            .send()
907            .await
908            .unwrap();
909        assert_eq!(response.status(), 200);
910
911        // Check with users' credentials
912        let response = reqwest::Client::new()
913            .get(api_url(&gitea, "/user/emails").await)
914            .basic_auth(TEST_ADMIN_USERNAME, Some(TEST_ADMIN_PASSWORD))
915            .send()
916            .await
917            .unwrap()
918            .json::<Value>()
919            .await
920            .unwrap();
921
922        let response = response.as_array().unwrap();
923        assert_eq!(response.len(), 1);
924    }
925}