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, 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        let ca_cert = ca_cert.self_signed(&ca_key).unwrap();
635
636        // prepare SANs
637        let mut hostnames = vec![
638            "localhost".to_string(),
639            "127.0.0.1".to_string(),
640            "::1".to_string(),
641        ];
642        let hostname = hostname.into();
643        if hostname != "localhost" {
644            hostnames.insert(0, hostname);
645        }
646
647        // and generate server key and cert
648        let key = KeyPair::generate().unwrap();
649        let cert = CertificateParams::new(hostnames)
650            .unwrap()
651            .signed_by(&key, &ca_cert, &ca_key)
652            .unwrap();
653
654        Self {
655            cert: cert.pem(),
656            key: key.serialize_pem(),
657            ca: Some(ca_cert.pem()),
658        }
659    }
660
661    /// Construct from externally provided certificate and key, without CA.
662    fn from_pem(cert: impl Into<String>, key: impl Into<String>) -> Self {
663        Self {
664            cert: cert.into(),
665            key: key.into(),
666            ca: None,
667        }
668    }
669
670    /// Return self-signed Root CA is it was generated.
671    fn ca(&self) -> Option<&str> {
672        self.ca.as_deref()
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use reqwest::Certificate;
679    use serde_json::Value;
680    use testcontainers::{runners::AsyncRunner, ContainerAsync};
681
682    use super::*;
683
684    const TEST_PUBLIC_KEY: &str =
685        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJRE5a67/cTbR6DpWqzBl6BTY0LE0Hg715ZI/FMK7iCH";
686    const TEST_ADMIN_USERNAME: &str = "non-default-user";
687    const TEST_ADMIN_PASSWORD: &str = "some-dummy-password";
688    const TEST_PUBLIC_REPO: &str = "test-public-repo";
689    const TEST_PRIVATE_REPO: &str = "test-private-repo";
690
691    async fn api_url(container: &ContainerAsync<Gitea>, api: &str) -> String {
692        let api = api.strip_prefix('/').unwrap_or(api);
693        let host = container.get_host().await.unwrap();
694        let port = container.get_host_port_ipv4(GITEA_HTTP_PORT).await.unwrap();
695
696        format!(
697            "{}://{host}:{port}/api/v1/{api}",
698            container.image().protocol(),
699        )
700    }
701
702    #[tokio::test]
703    async fn gitea_defaults() {
704        let gitea = Gitea::default().start().await.unwrap();
705
706        // Check for admin user
707        let response = reqwest::Client::new()
708            .get(api_url(&gitea, &format!("/users/{GITEA_DEFAULT_ADMIN_USERNAME}")).await)
709            .basic_auth(
710                GITEA_DEFAULT_ADMIN_USERNAME,
711                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
712            )
713            .send()
714            .await
715            .unwrap();
716
717        assert_eq!(response.status(), 200);
718
719        // Check for an admin user public key
720        let keys_list = reqwest::Client::new()
721            .get(api_url(&gitea, "/user/keys").await)
722            .basic_auth(
723                GITEA_DEFAULT_ADMIN_USERNAME,
724                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
725            )
726            .send()
727            .await
728            .unwrap()
729            .json::<Value>()
730            .await
731            .unwrap();
732
733        let keys_list = keys_list.as_array().unwrap();
734        assert!(keys_list.is_empty());
735    }
736
737    #[tokio::test]
738    async fn gitea_with_tls() {
739        let gitea = Gitea::default().with_tls(true).start().await.unwrap();
740
741        // Check w/o CA, should fail
742        let response = reqwest::Client::new()
743            .get(api_url(&gitea, &format!("/users/{GITEA_DEFAULT_ADMIN_USERNAME}")).await)
744            .basic_auth(
745                GITEA_DEFAULT_ADMIN_USERNAME,
746                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
747            )
748            .send()
749            .await;
750        assert!(response.is_err());
751
752        // Check with CA, should pass
753        let ca = gitea.image().tls_ca().unwrap();
754        let ca = Certificate::from_pem(ca.as_bytes()).unwrap();
755        let client = reqwest::ClientBuilder::new()
756            .use_rustls_tls()
757            .add_root_certificate(ca)
758            .build()
759            .unwrap();
760
761        let response = client
762            .get(api_url(&gitea, &format!("/users/{GITEA_DEFAULT_ADMIN_USERNAME}")).await)
763            .basic_auth(
764                GITEA_DEFAULT_ADMIN_USERNAME,
765                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
766            )
767            .send()
768            .await
769            .unwrap();
770        assert_eq!(response.status(), 200);
771    }
772
773    #[tokio::test]
774    async fn gitea_custom_admin_credentials() {
775        let gitea = Gitea::default()
776            .with_admin_account(
777                TEST_ADMIN_USERNAME,
778                TEST_ADMIN_PASSWORD,
779                Some(TEST_PUBLIC_KEY.to_string()),
780            )
781            .start()
782            .await
783            .unwrap();
784
785        // Check for an admin user public key with default credentials,
786        // fails since user doesn't exist
787        let response = reqwest::Client::new()
788            .get(api_url(&gitea, "/user/keys").await)
789            .basic_auth(
790                GITEA_DEFAULT_ADMIN_USERNAME,
791                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
792            )
793            .send()
794            .await
795            .unwrap();
796
797        assert_eq!(response.status(), 401);
798
799        // The same check with custom credentials should pass
800        let keys_list = reqwest::Client::new()
801            .get(api_url(&gitea, "/user/keys").await)
802            .basic_auth(TEST_ADMIN_USERNAME, Some(TEST_ADMIN_PASSWORD))
803            .send()
804            .await
805            .unwrap()
806            .json::<Value>()
807            .await
808            .unwrap();
809
810        let keys_list = keys_list.as_array().unwrap();
811        assert_eq!(keys_list.len(), 1);
812    }
813
814    #[tokio::test]
815    async fn gitea_create_repos() {
816        let gitea = Gitea::default()
817            .with_repo(GiteaRepo::Public(TEST_PUBLIC_REPO.to_string()))
818            .with_repo(GiteaRepo::Private(TEST_PRIVATE_REPO.to_string()))
819            .start()
820            .await
821            .unwrap();
822
823        // Check access to the public repo w/o auth
824        let response = reqwest::Client::new()
825            .get(
826                api_url(
827                    &gitea,
828                    &format!("/repos/{GITEA_DEFAULT_ADMIN_USERNAME}/{TEST_PUBLIC_REPO}"),
829                )
830                .await,
831            )
832            .send()
833            .await
834            .unwrap();
835
836        assert_eq!(response.status(), 200);
837
838        // Check access to the private repo w/o auth,
839        // should be 404
840        let response = reqwest::Client::new()
841            .get(
842                api_url(
843                    &gitea,
844                    &format!("/repos/{GITEA_DEFAULT_ADMIN_USERNAME}/{TEST_PRIVATE_REPO}"),
845                )
846                .await,
847            )
848            .send()
849            .await
850            .unwrap();
851
852        assert_eq!(response.status(), 404);
853
854        // Check access to the private repo with auth,
855        // should be 200
856        let response = reqwest::Client::new()
857            .get(
858                api_url(
859                    &gitea,
860                    &format!("/repos/{GITEA_DEFAULT_ADMIN_USERNAME}/{TEST_PRIVATE_REPO}"),
861                )
862                .await,
863            )
864            .basic_auth(
865                GITEA_DEFAULT_ADMIN_USERNAME,
866                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
867            )
868            .send()
869            .await
870            .unwrap();
871
872        assert_eq!(response.status(), 200);
873    }
874
875    #[tokio::test]
876    async fn gitea_admin_commands() {
877        let command = vec![
878            "user",
879            "create",
880            "--username",
881            TEST_ADMIN_USERNAME,
882            "--password",
883            TEST_ADMIN_PASSWORD,
884            "--email",
885            format!("{}@localhost", TEST_ADMIN_USERNAME).as_str(),
886            "--must-change-password=false",
887        ]
888        .into_iter()
889        .map(String::from)
890        .collect::<Vec<String>>();
891
892        let gitea = Gitea::default()
893            .with_admin_command(command)
894            .start()
895            .await
896            .unwrap();
897
898        // Check for new custom user
899        let response = reqwest::Client::new()
900            .get(api_url(&gitea, &format!("/users/{TEST_ADMIN_USERNAME}")).await)
901            .basic_auth(
902                GITEA_DEFAULT_ADMIN_USERNAME,
903                Some(GITEA_DEFAULT_ADMIN_PASSWORD),
904            )
905            .send()
906            .await
907            .unwrap();
908        assert_eq!(response.status(), 200);
909
910        // Check with users' credentials
911        let response = reqwest::Client::new()
912            .get(api_url(&gitea, "/user/emails").await)
913            .basic_auth(TEST_ADMIN_USERNAME, Some(TEST_ADMIN_PASSWORD))
914            .send()
915            .await
916            .unwrap()
917            .json::<Value>()
918            .await
919            .unwrap();
920
921        let response = response.as_array().unwrap();
922        assert_eq!(response.len(), 1);
923    }
924}