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}