Skip to content

Commit

Permalink
Add CLI command to configure TOS for an organization
Browse files Browse the repository at this point in the history
Closes #8778
  • Loading branch information
FirelightFlagboy committed Oct 24, 2024
1 parent 64f3638 commit aa9f644
Show file tree
Hide file tree
Showing 7 changed files with 301 additions and 9 deletions.
91 changes: 91 additions & 0 deletions cli/src/commands/tos/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
use std::{collections::HashMap, path::PathBuf};

use libparsec::{OrganizationID, ParsecAddr};

crate::clap_parser_with_shared_opts_builder!(
#[with = addr, token, organization]
pub struct Args {
/// If set, will remove TOS for the provided organization.
#[arg(long)]
remove: bool,
/// Read the TOS configuration from a JSON file.
#[arg(long)]
from_json: Option<PathBuf>,
/// List of locale and url for TOS configuration
/// Each arguments should be in the form of `{locale}={url}`
raw: Vec<String>,
}
);

pub async fn main(args: Args) -> anyhow::Result<()> {
let Args {
organization,
token,
addr,
raw,
remove,
from_json,
} = args;
log::trace!("Configure TOS for organization {organization} (addr={addr})");

let raw_data = from_json.map(std::fs::read_to_string).transpose()?;

let req = if let Some(ref raw_data) = raw_data {
let localized_tos_url = serde_json::from_str::<HashMap<_, _>>(raw_data)?;
TosReq::set_tos(localized_tos_url)
} else if remove {
TosReq::to_remove()
} else {
let localized_tos_url = raw
.iter()
.map(|arg| {
arg.split_once('=')
.ok_or_else(|| anyhow::anyhow!("Missing '=<URL>' in argument ('{arg}')"))
})
.collect::<anyhow::Result<HashMap<_, _>>>()?;
TosReq::set_tos(localized_tos_url)
};

config_tos_for_org_req(&addr, &token, &organization, req).await
}

#[derive(serde::Serialize)]
pub(crate) struct TosReq<'a> {
tos: Option<HashMap<&'a str, &'a str>>,
}

impl<'a> TosReq<'a> {
pub(crate) fn to_remove() -> Self {
Self { tos: None }
}

pub(crate) fn set_tos(tos: HashMap<&'a str, &'a str>) -> Self {
Self { tos: Some(tos) }
}
}

pub(crate) async fn config_tos_for_org_req<'a>(
addr: &ParsecAddr,
token: &'a str,
organization: &OrganizationID,
tos_req: TosReq<'a>,
) -> anyhow::Result<()> {
let url = addr.to_http_url(Some(&format!(
"/administration/organizations/{organization}"
)));
let client = libparsec_client_connection::build_client()?;
let rep = client
.patch(url)
.json(&tos_req)
.bearer_auth(token)
.send()
.await?;

match rep.status() {
reqwest::StatusCode::OK => Ok(()),
reqwest::StatusCode::NOT_FOUND => {
Err(anyhow::anyhow!("Organization {organization} not found"))
}
code => Err(anyhow::anyhow!("Unexpected HTTP status code {code}")),
}
}
6 changes: 5 additions & 1 deletion cli/src/commands/tos/mod.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
pub mod config;
pub mod list;

#[derive(clap::Subcommand)]
pub enum Group {
// List available Terms of Service
/// List available Terms of Service of an organization
List(list::Args),
/// Configure TOS for an organization
Config(config::Args),
}

pub async fn dispatch_command(command: Group) -> anyhow::Result<()> {
match command {
Group::List(args) => list::main(args).await,
Group::Config(args) => config::main(args).await,
}
}
27 changes: 26 additions & 1 deletion cli/src/macro_opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ macro_rules! clap_parser_with_shared_opts_builder {
}
);
};
// Device option
// Workspace option
(
#[with = workspace $(,$modifier:ident)*]
$(#[$struct_attr:meta])*
Expand All @@ -108,6 +108,31 @@ macro_rules! clap_parser_with_shared_opts_builder {
}
);
};
// Organization option
(
#[with = organization $(,$modifier:ident)*]
$(#[$struct_attr:meta])*
$visibility:vis struct $name:ident {
$(
$(#[$field_attr:meta])*
$field_vis:vis $field:ident: $field_type:ty,
)*
}
) => {
$crate::clap_parser_with_shared_opts_builder!(
#[with = $($modifier),*]
$(#[$struct_attr])*
$visibility struct $name {
#[doc = "Organization ID"]
#[arg(short, long, env = "PARSEC_ORGANIZATION_ID")]
pub(crate) organization: libparsec::OrganizationID,
$(
$(#[$field_attr])*
$field_vis $field: $field_type,
)*
}
);
};
// Password stdin option
(
#[with = password_stdin $(,$modifier:ident)*]
Expand Down
150 changes: 150 additions & 0 deletions cli/tests/integration/tos/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
use std::collections::HashMap;

use libparsec::{tmp_path, ClientGetTosError, TmpPath};

use crate::{
commands::tos::config::{config_tos_for_org_req, TosReq},
integration_tests::bootstrap_cli_test,
testenv_utils::{TestOrganization, DEFAULT_ADMINISTRATION_TOKEN},
utils::start_client,
};

#[rstest::rstest]
#[tokio::test]
async fn test_set_tos_from_arg(tmp_path: TmpPath) {
let (addr, TestOrganization { alice, .. }, organization) =
bootstrap_cli_test(&tmp_path).await.unwrap();

crate::assert_cmd_success!(
"tos",
"config",
"--organization",
organization.as_ref(),
"--token",
DEFAULT_ADMINISTRATION_TOKEN,
"--addr",
&addr.to_string(),
"fr_fr=http://example.com/tos",
"en_CA=http://example.com/en/tos"
)
.stdout(predicates::str::is_empty());

let client = start_client(alice).await.unwrap();

let tos = client.get_tos().await.unwrap();

assert_eq!(
tos.per_locale_urls,
HashMap::from_iter([
("fr_fr".into(), "http://example.com/tos".into()),
("en_CA".into(), "http://example.com/en/tos".into()),
])
);
}

#[rstest::rstest]
#[tokio::test]
async fn test_set_tos_from_file(tmp_path: TmpPath) {
let (addr, TestOrganization { alice, .. }, organization) =
bootstrap_cli_test(&tmp_path).await.unwrap();

let expected_tos = HashMap::from_iter([
("fr_fr".into(), "http://example.com/tos".into()),
("en_CA".into(), "http://example.com/en/tos".into()),
]);
let tos_file = tmp_path.join("tos.json");
tokio::fs::write(&tos_file, serde_json::to_vec(&expected_tos).unwrap())
.await
.unwrap();

crate::assert_cmd_success!(
"tos",
"config",
"--organization",
organization.as_ref(),
"--token",
DEFAULT_ADMINISTRATION_TOKEN,
"--addr",
&addr.to_string(),
"--from-json",
&tos_file.display().to_string()
)
.stdout(predicates::str::is_empty());

let client = start_client(alice).await.unwrap();

let tos = client.get_tos().await.unwrap();

assert_eq!(tos.per_locale_urls, expected_tos);
}

#[rstest::rstest]
#[tokio::test]
async fn test_remove_tos(tmp_path: TmpPath) {
let (addr, TestOrganization { alice, .. }, organization) =
bootstrap_cli_test(&tmp_path).await.unwrap();

config_tos_for_org_req(
&addr,
DEFAULT_ADMINISTRATION_TOKEN,
&organization,
TosReq::set_tos(HashMap::from_iter([("fr_fr", "http://parsec.local/tos")])),
)
.await
.unwrap();

let client = start_client(alice).await.unwrap();
let tos = client.get_tos().await.unwrap();

assert!(!tos.per_locale_urls.is_empty());

crate::assert_cmd_success!(
"tos",
"config",
"--organization",
organization.as_ref(),
"--token",
DEFAULT_ADMINISTRATION_TOKEN,
"--addr",
&addr.to_string(),
"--remove"
)
.stdout(predicates::str::is_empty());

let err = client.get_tos().await.unwrap_err();

assert!(matches!(err, ClientGetTosError::NoTos));
}

#[tokio::test]
async fn test_invalid_tos_arg() {
crate::assert_cmd_failure!(
"tos",
"config",
"--organization=foobar",
"--token=123456",
"--addr=parsec3://example.com",
"fr_fr"
)
.stderr(predicates::str::contains(
"Missing '=<URL>' in argument ('fr_fr')",
));
}

#[rstest::rstest]
#[tokio::test]
async fn test_org_not_found(tmp_path: TmpPath) {
let (addr, _, _) = bootstrap_cli_test(&tmp_path).await.unwrap();

crate::assert_cmd_failure!(
"tos",
"config",
"--organization=foobar",
"--token",
DEFAULT_ADMINISTRATION_TOKEN,
"--addr",
&addr.to_string(),
"--remove"
)
.stderr(predicates::str::contains("Organization foobar not found"));
}
34 changes: 27 additions & 7 deletions cli/tests/integration/tos/list.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
use std::collections::HashMap;

use libparsec::{tmp_path, TmpPath};
use predicates::prelude::PredicateBooleanExt;

use crate::{
commands::tos::config::{config_tos_for_org_req, TosReq},
integration_tests::bootstrap_cli_test,
testenv_utils::{TestOrganization, DEFAULT_DEVICE_PASSWORD},
testenv_utils::{TestOrganization, DEFAULT_ADMINISTRATION_TOKEN, DEFAULT_DEVICE_PASSWORD},
};

#[rstest::rstest]
Expand All @@ -20,12 +24,24 @@ async fn list_no_tos_available(tmp_path: TmpPath) {
.stdout(predicates::str::contains("No Terms of Service available"));
}

// FIXME: How to configure the TOS using the testbed server ?
#[should_panic]
#[rstest::rstest]
#[tokio::test]
async fn list_tos_ok(tmp_path: TmpPath) {
let (_, TestOrganization { alice, .. }, _) = bootstrap_cli_test(&tmp_path).await.unwrap();
let (addr, TestOrganization { alice, .. }, organization) =
bootstrap_cli_test(&tmp_path).await.unwrap();

let tos = HashMap::from_iter([
("fr_FR", "http://example.com/tos"),
("en_DK", "http://example.com/en/tos"),
]);
config_tos_for_org_req(
&addr,
DEFAULT_ADMINISTRATION_TOKEN,
&organization,
TosReq::set_tos(tos),
)
.await
.unwrap();

crate::assert_cmd_success!(
with_password = DEFAULT_DEVICE_PASSWORD,
Expand All @@ -34,7 +50,11 @@ async fn list_tos_ok(tmp_path: TmpPath) {
"--device",
&alice.device_id.hex()
)
.stdout(predicates::str::contains(
"Terms of Service updated on __TODO__:",
));
.stdout(
predicates::str::contains("Terms of Service updated on")
.and(predicates::str::contains("- fr_FR: http://example.com/tos"))
.and(predicates::str::contains(
"- en_DK: http://example.com/en/tos",
)),
);
}
1 change: 1 addition & 0 deletions cli/tests/integration/tos/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
mod config;
mod list;
1 change: 1 addition & 0 deletions newsfragments/8778.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add CLI Command to configure TOS for an organization

0 comments on commit aa9f644

Please sign in to comment.