use crate::{
approvals::{ApprovalVerdict, OperatorApproval, ValidatedManifest},
client::build_client,
commands::Run,
config::turnkey::Config,
errors::MissingResource,
local_operator_key::LocalOperatorSeedSource,
operator::{OperatorCtx, ResolvedOperator, resolve_operator},
outcome::Outcome,
output::StdCtx,
pair::HexSeed,
prompts::{self, bail_required_in_non_interactive, stdin_can_prompt},
shell_print, shell_println,
util::{read_file_to_string, write_file},
};
use anyhow::{Context, bail};
use clap::{ArgGroup, Args as ClapArgs};
use displaydoc::Display;
use qos_core::protocol::services::boot::{
Approval, ManifestSet, Namespace, NitroConfig, QuorumMember, ShareSet, VersionedManifest,
};
use serde::Serialize;
use std::fmt::{self, Formatter, Write};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{
ops::Deref,
path::{Path, PathBuf},
};
use tracing::{debug, instrument};
use turnkey_client::generated::{
CreateTvcManifestApprovalsIntent, GetTvcDeploymentRequest, TvcManifestApproval,
};
use uuid::Uuid;
const QUORUM_REACHED_MESSAGE: &str =
"Manifest approval quorum reached. Your deployment will be available soon.";
#[derive(Debug, ClapArgs)]
#[command(about, long_about = None)]
#[command(group(ArgGroup::new("manifest-source").args(["manifest", "deploy_id"])))]
pub struct Args {
#[arg(
short,
long,
value_name = "PATH",
env = "TVC_MANIFEST",
help_heading = "Manifest source (pick one)"
)]
pub manifest: Option<PathBuf>,
#[arg(
short,
long,
env = "TVC_DEPLOY_ID",
help_heading = "Manifest source (pick one)"
)]
pub deploy_id: Option<Uuid>,
#[arg(long, env = "TVC_MANIFEST_ID")]
pub manifest_id: Option<Uuid>,
#[arg(long, env = "TVC_OPERATOR_ID")]
pub operator_id: Option<Uuid>,
#[arg(
long,
value_name = "HEX_SEED",
env = "TVC_OPERATOR_SEED",
help_heading = "Operator seed (pick one)"
)]
pub operator_seed: Option<HexSeed>,
#[arg(
long,
value_name = "PATH",
env = "TVC_OPERATOR_SEED_PATH",
help_heading = "Operator seed (pick one)"
)]
pub operator_seed_path: Option<PathBuf>,
#[arg(long, env = "TVC_DRY_RUN")]
pub dry_run: bool,
#[arg(long, env = "TVC_DANGEROUS_SKIP_INTERACTIVE")]
pub dangerous_skip_interactive: bool,
#[arg(short = 'o', long, value_name = "PATH", env = "TVC_APPROVAL_OUT")]
pub approval_out: Option<PathBuf>,
#[arg(long, env = "TVC_SKIP_POST")]
pub skip_post: bool,
}
impl Run for Args {
type Outcome = ApproveOutcome;
async fn run(self, ctx: &mut StdCtx) -> anyhow::Result<Self::Outcome> {
let args = ArgsWithResolvedOperatorSeedSource::try_from(self)?;
if !args.dangerous_skip_interactive && (ctx.is_non_interactive() || !stdin_can_prompt()) {
bail_required_in_non_interactive("--dangerous-skip-interactive")?;
}
let (manifest, fetched) = load_manifest(ctx, &args).await?;
let manifest = ValidatedManifest::try_from(&manifest)?;
if !args.dangerous_skip_interactive {
interactive_approve(ctx, &manifest)?;
}
let (post_target, operator_id) = build_post_target(
BuildPostTargetArgs::from(&args),
fetched.as_ref().map(|(id, _)| *id),
ctx.is_non_interactive(),
)
.await?;
let inputs = ResolvedApproveInputs {
manifest,
operator_seed_source: args.operator_seed_source,
operator_id,
approval_out: args.approval_out,
dry_run: args.dry_run,
skip_post: args.skip_post,
post_target,
posted_approvals: fetched.map(|(_, approvals)| approvals).unwrap_or_default(),
};
run_with_resolved_inputs(ctx, inputs).await
}
}
struct PostApprovalPlan<'a> {
manifest_id: Uuid,
operator_id: &'a Uuid,
deploy_id: Option<&'a Uuid>,
}
struct PostedApproval {
approval_ids: Vec<String>,
quorum_reached: Option<bool>,
}
pub enum ApproveOutcome {
Posted(ApprovalPosted),
NotPosted(ApprovalGenerated),
AlreadyPosted(ApprovalAlreadyPosted),
DryRun(ApprovalDryRun),
}
impl From<ApproveOutcome> for Outcome {
fn from(outcome: ApproveOutcome) -> Self {
match outcome {
ApproveOutcome::Posted(msg) => Outcome::ManifestApprovalPosted(msg),
ApproveOutcome::NotPosted(msg) => Outcome::ManifestApprovalGenerated(msg),
ApproveOutcome::AlreadyPosted(msg) => Outcome::ManifestApprovalAlreadyPosted(msg),
ApproveOutcome::DryRun(msg) => Outcome::ManifestApprovalDryRun(msg),
}
}
}
#[derive(Display, Serialize)]
#[serde(rename_all = "camelCase")]
enum ApprovalOutput {
WrittenTo(PathBuf),
Approval(JsonPretty<Approval>),
}
#[derive(Serialize)]
struct JsonPretty<T>(T);
impl<T: Serialize> std::fmt::Display for JsonPretty<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let json = serde_json::to_string_pretty(self).map_err(|_| fmt::Error)?;
f.write_str(&json)
}
}
impl<T> Deref for JsonPretty<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> From<T> for JsonPretty<T> {
fn from(value: T) -> Self {
Self(value)
}
}
impl ApprovalOutput {
fn new(approval: Approval, approval_out: Option<PathBuf>) -> Self {
match approval_out {
Some(path) => Self::WrittenTo(path),
None => Self::Approval(approval.into()),
}
}
}
#[cfg(test)]
impl Default for ApprovalOutput {
fn default() -> Self {
Self::WrittenTo(Default::default())
}
}
#[derive(Serialize)]
#[cfg_attr(test, derive(Default))]
#[serde(rename_all = "camelCase")]
pub struct ApprovalPosted {
#[serde(flatten)]
approval_or_path: ApprovalOutput,
manifest_id: String,
operator_id: String,
approval_ids: Vec<String>,
quorum_reached: Option<bool>,
}
impl fmt::Display for ApprovalPosted {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(
f,
r#"{}
Approval posted successfully!
Approval IDs: {:?}
Manifest ID: {}
Operator ID: {}"#,
self.approval_or_path, self.approval_ids, self.manifest_id, self.operator_id
)?;
if let Some(reached) = self.quorum_reached {
let quorum_line = if reached {
QUORUM_REACHED_MESSAGE
} else {
"Your approval has been posted. Deployment requires additional manifest approvals before it can be deployed on TVC."
};
write!(f, "\n\n{quorum_line}")?;
}
Ok(())
}
}
#[derive(Display, Serialize)]
#[cfg_attr(test, derive(Default))]
pub struct ApprovalGenerated(ApprovalOutput);
#[derive(Display, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ApprovalAlreadyPosted {
operator_id: String,
approval_id: String,
}
#[derive(Default, Display, Serialize)]
pub struct ApprovalDryRun;
struct PostTarget {
manifest_id: Uuid,
deploy_id: Option<Uuid>,
}
#[derive(Debug, thiserror::Error)]
enum ApproveInputError {
#[error(
"--manifest-id is required to post approval to API (or use --deploy-id). \
Use --skip-post to only generate the approval locally."
)]
MissingManifestId,
}
struct BuildPostTargetArgs {
manifest_id: Option<Uuid>,
operator_id: Option<Uuid>,
deploy_id: Option<Uuid>,
dry_run: bool,
skip_post: bool,
}
impl From<&ArgsWithResolvedOperatorSeedSource> for BuildPostTargetArgs {
fn from(args: &ArgsWithResolvedOperatorSeedSource) -> Self {
Self {
manifest_id: args.manifest_id,
operator_id: args.operator_id,
deploy_id: args.deploy_id,
dry_run: args.dry_run,
skip_post: args.skip_post,
}
}
}
struct ResolvedApproveInputs<'a> {
manifest: ValidatedManifest<'a>,
operator_seed_source: Option<LocalOperatorSeedSource>,
operator_id: Option<Uuid>,
approval_out: Option<PathBuf>,
dry_run: bool,
skip_post: bool,
post_target: Option<PostTarget>,
posted_approvals: Vec<OperatorApproval>,
}
struct ArgsWithResolvedOperatorSeedSource {
manifest: Option<PathBuf>,
deploy_id: Option<Uuid>,
manifest_id: Option<Uuid>,
operator_id: Option<Uuid>,
operator_seed_source: Option<LocalOperatorSeedSource>,
dry_run: bool,
dangerous_skip_interactive: bool,
approval_out: Option<PathBuf>,
skip_post: bool,
}
impl TryFrom<Args> for ArgsWithResolvedOperatorSeedSource {
type Error = anyhow::Error;
fn try_from(args: Args) -> anyhow::Result<Self> {
let Args {
operator_seed,
operator_seed_path,
manifest,
deploy_id,
manifest_id,
operator_id,
dry_run,
dangerous_skip_interactive,
approval_out,
skip_post,
} = args;
let operator_seed_source =
LocalOperatorSeedSource::from_args(operator_seed, operator_seed_path)?;
Ok(Self {
manifest,
deploy_id,
manifest_id,
operator_id,
operator_seed_source,
dry_run,
dangerous_skip_interactive,
approval_out,
skip_post,
})
}
}
async fn build_post_target(
args: BuildPostTargetArgs,
fetched_manifest_id: Option<Uuid>,
non_interactive: bool,
) -> anyhow::Result<(Option<PostTarget>, Option<Uuid>)> {
if args.dry_run || args.skip_post {
return Ok((None, args.operator_id));
}
let manifest_id = fetched_manifest_id
.or(args.manifest_id)
.ok_or(ApproveInputError::MissingManifestId)?;
let operator_id = if let Some(operator_id) = args.operator_id {
operator_id
} else {
let config = Config::load().await?;
let saved_ids = config
.get_last_operator_ids()
.unwrap_or_default()
.into_iter()
.map(|id| {
Uuid::parse_str(&id)
.with_context(|| format!("saved operator ID '{id}' is not a UUID"))
})
.collect::<anyhow::Result<Vec<_>>>()?;
match saved_ids.len() {
0 => bail!(
"--operator-id is required to post approval to API. \
No saved operator IDs found. \
Use --skip-post to only generate the approval locally."
),
1 => saved_ids[0],
_ if non_interactive || !stdin_can_prompt() => bail!(
"--operator-id is required to post approval to API when multiple saved operator IDs are available"
),
_ => prompts::select("Select approving operator", saved_ids)?,
}
};
let post_target = PostTarget {
manifest_id,
deploy_id: args.deploy_id,
};
Ok((post_target.into(), operator_id.into()))
}
async fn run_with_resolved_inputs(
ctx: &mut StdCtx,
inputs: ResolvedApproveInputs<'_>,
) -> anyhow::Result<ApproveOutcome> {
if inputs.dry_run {
return Ok(ApproveOutcome::DryRun(ApprovalDryRun));
}
let validation = inputs.manifest.validate(inputs.posted_approvals);
validation
.approvals
.iter()
.filter(|validated| validated.verdict != ApprovalVerdict::Valid)
.for_each(|validated| {
let message = format!(
"existing approval from {} is {}; enclave will reject this approval and fail to start",
validated.approval, validated.verdict
);
if let Err(error) = ctx.shell().human().warn(message) {
debug!(%error, "failed to write approval warning");
}
});
let operator = resolve_operator(inputs.operator_seed_source, inputs.operator_id).await?;
if inputs.skip_post && operator.is_hosted() {
bail!("--skip-post is only supported for local operators");
}
let auth = if operator.is_hosted() {
Some(build_client().await?)
} else {
None
};
let approval = sign_and_write_approval(
&operator,
&OperatorCtx {
auth: auth.as_ref(),
},
inputs.approval_out.as_deref(),
&inputs.manifest,
)
.await?;
match inputs.post_target {
Some(target) => {
let operator_id = operator
.id()
.context("resolved operator ID required to post approval")?;
let existing = validation.approvals.iter().find(|validated| {
validated.approval.operator_id == operator_id
|| validated.approval.public_key.to_bytes() == approval.member.pub_key
});
if let Some(existing) = existing {
return Ok(ApproveOutcome::AlreadyPosted(ApprovalAlreadyPosted {
operator_id: operator_id.to_string(),
approval_id: existing.approval.id.to_string(),
}));
}
let plan = PostApprovalPlan {
manifest_id: target.manifest_id,
operator_id: &operator_id,
deploy_id: target.deploy_id.as_ref(),
};
let posted = post_approval_to_api(ctx, plan, &approval, &inputs.manifest).await?;
Ok(ApproveOutcome::Posted(ApprovalPosted {
approval_or_path: ApprovalOutput::new(approval, inputs.approval_out),
manifest_id: target.manifest_id.to_string(),
operator_id: operator_id.to_string(),
approval_ids: posted.approval_ids,
quorum_reached: posted.quorum_reached,
}))
}
None => Ok(ApproveOutcome::NotPosted(ApprovalGenerated(
ApprovalOutput::new(approval, inputs.approval_out),
))),
}
}
async fn load_manifest(
ctx: &mut StdCtx,
args: &ArgsWithResolvedOperatorSeedSource,
) -> anyhow::Result<(VersionedManifest, Option<(Uuid, Vec<OperatorApproval>)>)> {
match (&args.manifest, &args.deploy_id) {
(Some(path), _) => Ok((read_manifest_from_path(path).await?, None)),
(_, Some(deploy_id)) => {
let (manifest, manifest_id, approvals) =
fetch_manifest_from_deploy(ctx, &deploy_id.to_string()).await?;
Ok((manifest, Some((manifest_id, approvals))))
}
(None, None) => bail!("a manifest source is required"),
}
}
async fn sign_and_write_approval(
operator: &ResolvedOperator,
operator_ctx: &OperatorCtx<'_>,
approval_out: Option<&Path>,
manifest: &ValidatedManifest<'_>,
) -> anyhow::Result<Approval> {
let approval = operator.approve_manifest(operator_ctx, manifest).await?;
if let Some(path) = approval_out {
let json = serde_json::to_string_pretty(&approval)?;
write_file(path, &json).await?;
}
Ok(approval)
}
#[instrument(skip_all, fields(manifest_id = %plan.manifest_id, operator_id = %plan.operator_id, deploy_id = ?plan.deploy_id))]
async fn post_approval_to_api(
ctx: &mut StdCtx,
plan: PostApprovalPlan<'_>,
approval: &Approval,
manifest: &ValidatedManifest<'_>,
) -> anyhow::Result<PostedApproval> {
shell_println!(ctx)?;
shell_println!(ctx, "Posting approval to Turnkey...")?;
let auth = crate::client::build_client().await?;
let tvc_approval = TvcManifestApproval {
operator_id: plan.operator_id.to_string(),
signature: hex::encode(&approval.signature),
};
let intent = CreateTvcManifestApprovalsIntent {
manifest_id: plan.manifest_id.to_string(),
approvals: vec![tvc_approval],
};
let timestamp_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system time before unix epoch")?
.as_millis();
let result = auth
.client
.create_tvc_manifest_approvals(auth.org_id.clone(), timestamp_ms, intent)
.await
.context("failed to post manifest approval")?;
let quorum_reached = match plan.deploy_id {
Some(deploy_id) => {
let request = GetTvcDeploymentRequest {
organization_id: auth.org_id.clone(),
deployment_id: deploy_id.to_string(),
};
match auth.client.get_tvc_deployment(request).await {
Ok(response) => match response.tvc_deployment {
Some(deployment) => {
let approvals = deployment
.manifest_approvals
.into_iter()
.map(OperatorApproval::try_from)
.collect::<Result<Vec<_>, _>>()?;
Some(manifest.validate(approvals).quorum_reached())
}
None => Some(false),
},
Err(error) => {
debug!(
%deploy_id,
%error,
"failed to fetch deployment after posting manifest approval"
);
None
}
}
}
None => None,
};
Ok(PostedApproval {
approval_ids: result.result.approval_ids,
quorum_reached,
})
}
fn interactive_approve(ctx: &mut StdCtx, manifest: &VersionedManifest) -> anyhow::Result<()> {
shell_println!(ctx, "\n========================================")?;
shell_println!(ctx, " MANIFEST APPROVAL")?;
shell_println!(ctx, "========================================\n")?;
review_namespace(ctx, manifest.namespace())?;
review_enclave(ctx, manifest.enclave())?;
review_pivot(ctx, manifest)?;
review_manifest_set(ctx, manifest.manifest_set())?;
review_share_set(ctx, manifest.share_set())?;
shell_println!(ctx, "\n========================================")?;
shell_println!(ctx, " ALL SECTIONS APPROVED")?;
shell_println!(ctx, "========================================\n")?;
Ok(())
}
fn render_namespace(namespace: &Namespace) -> String {
let mut s = String::new();
let _ = writeln!(s, "NAMESPACE");
let _ = writeln!(s, "─────────────────────────────────────");
let _ = writeln!(s, " Name: {}", namespace.name);
let _ = writeln!(s, " Nonce: {}", namespace.nonce);
let _ = writeln!(s, " Quorum Key: {}", hex::encode(&namespace.quorum_key));
let _ = writeln!(s);
s
}
fn review_namespace(ctx: &mut StdCtx, namespace: &Namespace) -> anyhow::Result<()> {
shell_print!(ctx, "{}", render_namespace(namespace))?;
prompts::confirm_or_bail("Approve namespace?", "approval")
}
fn render_enclave(enclave: &NitroConfig) -> String {
let mut s = String::new();
let _ = writeln!(s, "ENCLAVE (AWS Nitro)");
let _ = writeln!(s, "─────────────────────────────────────");
let _ = writeln!(s, " PCR0 (image): {}", hex::encode(&enclave.pcr0));
let _ = writeln!(s, " PCR1 (kernel): {}", hex::encode(&enclave.pcr1));
let _ = writeln!(s, " PCR2 (app): {}", hex::encode(&enclave.pcr2));
let _ = writeln!(s, " PCR3 (IAM role): {}", hex::encode(&enclave.pcr3));
let _ = writeln!(s);
s
}
fn review_enclave(ctx: &mut StdCtx, enclave: &NitroConfig) -> anyhow::Result<()> {
shell_print!(ctx, "{}", render_enclave(enclave))?;
prompts::confirm_or_bail("Approve enclave configuration?", "approval")
}
fn render_pivot(manifest: &VersionedManifest) -> String {
let mut s = String::new();
let _ = writeln!(s, "PIVOT BINARY");
let _ = writeln!(s, "─────────────────────────────────────");
let _ = writeln!(
s,
" Pivot Binary Hash: {}",
hex::encode(manifest.pivot_hash())
);
if manifest.args().is_empty() {
let _ = writeln!(s, " CLI Args: (none)");
} else {
let _ = writeln!(s, " CLI Args:\n {}", manifest.args().join("\n "));
}
let _ = writeln!(s);
s
}
fn review_pivot(ctx: &mut StdCtx, manifest: &VersionedManifest) -> anyhow::Result<()> {
shell_print!(ctx, "{}", render_pivot(manifest))?;
prompts::confirm_or_bail("Approve pivot binary?", "approval")
}
fn render_quorum_members(members: &[QuorumMember]) -> String {
let mut s = String::new();
for member in members.iter() {
let _ = writeln!(s, " {} ({})", member.alias, hex::encode(&member.pub_key));
}
s
}
fn render_manifest_set(set: &ManifestSet) -> String {
let mut s = String::new();
let _ = writeln!(s, "MANIFEST SET");
let _ = writeln!(s, "─────────────────────────────────────");
let _ = writeln!(s, " Threshold: {} of {}", set.threshold, set.members.len());
let _ = writeln!(s, " Members:");
s.push_str(&render_quorum_members(&set.members));
let _ = writeln!(s);
s
}
fn review_manifest_set(ctx: &mut StdCtx, set: &ManifestSet) -> anyhow::Result<()> {
shell_print!(ctx, "{}", render_manifest_set(set))?;
prompts::confirm_or_bail("Approve manifest set?", "approval")
}
fn render_share_set(set: &ShareSet) -> String {
let mut s = String::new();
let _ = writeln!(s, "SHARE SET");
let _ = writeln!(s, "─────────────────────────────────────");
let _ = writeln!(s, " Threshold: {} of {}", set.threshold, set.members.len());
let _ = writeln!(s, " Members:");
s.push_str(&render_quorum_members(&set.members));
let _ = writeln!(s);
s
}
fn review_share_set(ctx: &mut StdCtx, set: &ShareSet) -> anyhow::Result<()> {
shell_print!(ctx, "{}", render_share_set(set))?;
prompts::confirm_or_bail("Approve share set?", "approval")
}
async fn read_manifest_from_path(path: &Path) -> anyhow::Result<VersionedManifest> {
let content = read_file_to_string(path).await?;
let manifest = VersionedManifest::try_from_slice_compat(content.as_bytes())
.with_context(|| format!("failed to parse manifest JSON from: {}", path.display()))?;
Ok(manifest)
}
#[instrument(skip(ctx))]
async fn fetch_manifest_from_deploy(
ctx: &mut StdCtx,
deploy_id: &str,
) -> anyhow::Result<(VersionedManifest, Uuid, Vec<OperatorApproval>)> {
shell_println!(ctx, "Fetching deployment {deploy_id}...")?;
let auth = build_client().await?;
let request = GetTvcDeploymentRequest {
organization_id: auth.org_id.clone(),
deployment_id: deploy_id.to_string(),
};
let response = auth
.client
.get_tvc_deployment(request)
.await
.with_context(|| format!("failed to fetch deployment {deploy_id}"))?;
let deployment = response
.tvc_deployment
.ok_or_else(|| MissingResource::new("deployment", deploy_id.to_string()))?;
let tvc_manifest = deployment
.manifest
.as_ref()
.ok_or_else(|| MissingResource::new("manifest", format!("deployment {deploy_id}")))?;
let manifest = VersionedManifest::try_from_slice_compat(&tvc_manifest.manifest)
.context("failed to parse manifest from deployment")?;
let manifest_id = tvc_manifest
.id
.parse::<Uuid>()
.with_context(|| format!("manifest ID '{}' is not a UUID", tvc_manifest.id))?;
let approvals = deployment
.manifest_approvals
.into_iter()
.map(OperatorApproval::try_from)
.collect::<Result<Vec<_>, _>>()?;
shell_println!(ctx, "✓ Manifest loaded (manifest_id: {manifest_id})")?;
Ok((manifest, manifest_id, approvals))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_manifest() -> VersionedManifest {
VersionedManifest::try_from_slice_compat(include_bytes!("../../../fixtures/manifest.json"))
.expect("fixture manifest should parse")
}
#[test]
fn render_namespace_includes_name_nonce_and_quorum_key() {
let manifest = fixture_manifest();
let rendered = render_namespace(manifest.namespace());
assert!(rendered.contains("NAMESPACE"));
assert!(rendered.contains("turnkey-prod"));
assert!(rendered.contains("Nonce:"));
assert!(rendered.contains("Quorum Key:"));
}
#[test]
fn render_enclave_includes_all_four_pcrs() {
let manifest = fixture_manifest();
let rendered = render_enclave(manifest.enclave());
assert!(rendered.contains("ENCLAVE (AWS Nitro)"));
assert!(rendered.contains("PCR0"));
assert!(rendered.contains("PCR1"));
assert!(rendered.contains("PCR2"));
assert!(rendered.contains("PCR3"));
}
#[test]
fn render_pivot_includes_header_and_args() {
let manifest = fixture_manifest();
let rendered = render_pivot(&manifest);
assert!(rendered.contains("PIVOT BINARY"));
assert!(rendered.contains("Pivot Binary Hash:"));
assert!(rendered.contains("--flag"));
assert!(rendered.contains("positional"));
}
#[test]
fn render_manifest_set_includes_threshold_and_each_member() {
let manifest = fixture_manifest();
let rendered = render_manifest_set(manifest.manifest_set());
assert!(rendered.contains("MANIFEST SET"));
assert!(rendered.contains("Threshold: 2 of 3"));
assert!(rendered.contains("operator-alice"));
assert!(rendered.contains("operator-bob"));
assert!(rendered.contains("operator-charlie"));
}
fn posted_to_file() -> ApprovalPosted {
ApprovalPosted {
approval_or_path: ApprovalOutput::WrittenTo("approval.json".into()),
manifest_id: "manifest-123".to_string(),
operator_id: "operator-456".to_string(),
approval_ids: vec!["approval-1".to_string()],
quorum_reached: Some(true),
}
}
#[test]
fn approval_posted_serializes_expected_json() {
let value =
serde_json::to_value(Outcome::from(ApproveOutcome::Posted(posted_to_file()))).unwrap();
assert_eq!(
value,
serde_json::json!({
"reason": "manifest_approval_posted",
"writtenTo": "approval.json",
"manifestId": "manifest-123",
"operatorId": "operator-456",
"approvalIds": ["approval-1"],
"quorumReached": true,
})
);
}
#[test]
fn approval_generated_serializes_expected_json() {
let generated = ApprovalGenerated(ApprovalOutput::Approval(
Approval {
signature: vec![0xde, 0xad],
member: QuorumMember {
alias: "operator-alice".to_string(),
pub_key: vec![0xaa],
},
}
.into(),
));
let value =
serde_json::to_value(Outcome::from(ApproveOutcome::NotPosted(generated))).unwrap();
assert_eq!(
value,
serde_json::json!({
"reason": "manifest_approval_generated",
"approval": {
"signature": "dead",
"member": {
"alias": "operator-alice",
"pubKey": "aa",
},
},
"reason": "manifest_approval_generated"
})
);
}
#[test]
fn approval_already_posted_serializes_expected_json() {
let already_posted = ApprovalAlreadyPosted {
operator_id: "operator-456".to_string(),
approval_id: "approval-1".to_string(),
};
let value =
serde_json::to_value(Outcome::from(ApproveOutcome::AlreadyPosted(already_posted)))
.unwrap();
assert_eq!(
value,
serde_json::json!({
"reason": "manifest_approval_already_posted",
"operatorId": "operator-456",
"approvalId": "approval-1",
})
);
}
#[test]
fn approval_dry_run_serializes_reason_only() {
let value =
serde_json::to_value(Outcome::from(ApproveOutcome::DryRun(ApprovalDryRun))).unwrap();
assert_eq!(
value,
serde_json::json!({ "reason": "manifest_approval_dry_run" })
);
}
#[test]
fn approval_posted_human_message_includes_quorum_line_only_when_known() {
let mut posted = posted_to_file();
posted.quorum_reached = Some(true);
assert!(
posted
.to_string()
.contains("Manifest approval quorum reached.")
);
posted.quorum_reached = Some(false);
assert!(
posted
.to_string()
.contains("requires additional manifest approvals")
);
posted.quorum_reached = None;
let human = posted.to_string();
assert!(!human.contains("quorum"));
assert!(human.ends_with("Operator ID: operator-456"));
}
}